From 3917a95eab875c023623a6bbedd5c303d8a6ee89 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 27 Sep 2026 20:49:55 -0400 Subject: [PATCH] fix(runtime): mount the project a composed service runs from in container mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every process the container environment runs is given the project directory as its working directory, but the environment was built over the workspace path alone. That carries the project only for a service authored in the workspace tree. A service a workspace *composes* is resolved into the module cache, or to wherever an overlay points — both outside the workspace — so the container started with a working directory that does not exist inside it: OCI runtime exec failed: exec failed: unable to start container process: chdir to cwd (".../services//code") set in config.json failed: no such file or directory which the caller reports as `cannot run uv sync: process exited with code 127`, naming uv rather than the missing mount. No composed Python service could run under `--runtime-context container`; the native and Nix backends were unaffected, which is why this survived — the module cache has always been outside the workspace, so this is not new with overlays. The same file already gets it right at the image-runtime environment (`runtime.go:1165`, which passes `s.Service.SourceLocation`); the asymmetry inside one file was the tell. Classification: a fix, in the agent that owns the runner environment. The workspace mount stays for a service that lives in the workspace, and the project is mounted at its own path only when that mount does not already cover it, so nothing changes for a service the workspace holds. Testing: `go build`, `go vet`, `go mod tidy -diff`, `go test ./...` (81s, ok — the Docker-backed cases ran, `nix` skipped as it always does here), and `go test -race ./...`. The new test fails on `main` with exactly the 127 above and passes with the fix. Not verified: Kubernetes deployment paths, which this does not touch. Co-Authored-By: Claude Opus 5 (1M context) --- composed_project_test.go | 89 ++++++++++++++++++++++++++++++++++++++++ runtime.go | 12 ++++++ 2 files changed, 101 insertions(+) create mode 100644 composed_project_test.go diff --git a/composed_project_test.go b/composed_project_test.go new file mode 100644 index 0000000..1185516 --- /dev/null +++ b/composed_project_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/shared" + "github.com/codefly-dev/core/standards" + "github.com/stretchr/testify/require" +) + +// TestContainerEnvironmentCarriesAProjectOutsideTheWorkspace pins the mount a +// composed service depends on. +// +// A service the user authored lives in the workspace tree; a service a +// workspace *composes* does not — it is resolved into the module cache, or to +// wherever an overlay points. Both are outside the workspace path, and every +// process this environment runs is given the project directory as its working +// directory. Mounting only the workspace therefore produced a container whose +// working directory did not exist inside it: "chdir to cwd ... no such file or +// directory", surfacing as `uv sync` exiting 127 before the service ever +// started. +func TestContainerEnvironmentCarriesAProjectOutsideTheWorkspace(t *testing.T) { + ownTestContainers(t) + ctx := context.Background() + + // Docker on macOS only bind-mounts shared paths, and the per-user temp dir + // is not one — keep both trees inside the repo, as the other container + // tests do. + workspace, err := os.MkdirTemp("testdata", "composed-workspace-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(workspace) }) + workspace = shared.MustSolvePath(workspace) + + source, err := os.MkdirTemp("testdata", "composed-project-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(source) }) + source = shared.MustSolvePath(source) + 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)) + + runtime := NewRuntime(NewService()) + runtime.Location = workspace + runtime.Logger = runtime.Wool + runtime.Base.Runtime.RuntimeContext = resources.NewRuntimeContextContainer() + runtime.Identity = &resources.ServiceIdentity{ + Name: fmt.Sprintf("composed-%d", time.Now().UnixMilli()), + Module: "mod", + Workspace: "test", + WorkspacePath: workspace, + } + runtime.Environment = shared.Must(resources.LocalEnvironment().Proto()) + runtime.Service.SourceLocation = source + + endpoint := &basev0.Endpoint{Module: "mod", Service: runtime.Identity.Name, Name: "rest", Api: standards.REST} + runtime.FastAPI.RestEndpoint = endpoint + runtime.NetworkMappings = []*basev0.NetworkMapping{{ + Endpoint: endpoint, + Instances: []*basev0.NetworkInstance{{ + Access: resources.NewNativeNetworkAccess(), + Hostname: "localhost", + Port: 9998, + Address: "http://localhost:9998", + }}, + }} + + require.False(t, within(workspace, source), + "the fixture must place the project outside the workspace, as a composed module is") + + env, _, err := runtime.CreateRunnerEnvironment(ctx) + if err != nil { + t.Skipf("docker is not available: %v", err) + } + t.Cleanup(func() { _ = env.Shutdown(context.Background()) }) + require.NoError(t, env.Init(ctx)) + + proc, err := env.NewProcess("sh", "-c", "test -f pyproject.toml") + require.NoError(t, err) + proc.WithDir(source) + require.NoError(t, proc.Run(ctx), + "the composed project must exist at its own path inside the container") +} diff --git a/runtime.go b/runtime.go index f7e86d6..44d53ad 100644 --- a/runtime.go +++ b/runtime.go @@ -255,6 +255,18 @@ func (s *Runtime) CreateRunnerEnvironment(ctx context.Context) (runners.RunnerEn if err != nil { return nil, "", s.Wool.Wrapf(err, "cannot create docker runner") } + // Every process this environment runs is given the project directory as + // its working directory, so the project has to be inside a mount. The + // workspace mount above carries it only for a service that lives in the + // workspace tree. A composed service does not: it is resolved into the + // module cache, or to wherever an overlay points, both outside the + // workspace — and the container then starts with a working directory + // that does not exist inside it ("chdir to cwd ... no such file or + // directory", reported as `uv sync` exiting 127). Mount the project at + // its own path when the workspace mount does not already cover it. + if !within(s.Identity.WorkspacePath, s.Service.SourceLocation) { + dockerEnv.WithMount(s.Service.SourceLocation, s.Service.SourceLocation) + } dockerEnv.WithPause() // Run as the invoking host user. uv sync writes uv.lock into the // bind-mounted source and populates the venv; as root those files