Skip to content

Commit 22d4fc3

Browse files
fix: assemble a build context for uv path sources and pin the uv that resolves it (#56)
Two independent defects made this recipe emit an image build that resolved on the developer's machine and failed inside the builder stage. A `[tool.uv.sources]` path is resolved against the directory of the pyproject.toml that declares it, and uv.lock records the same relative path. The recipe's context was the service directory and nothing beside it, so a path naming a sibling service was absent from the builder stage and `uv sync --frozen` failed there with error: Could not compute absolute path from workspace root and lockfile path Two services sharing one Python project in a repository is a layout this recipe has to build. It now assembles a context when, and only when, a path leaves the project: the service's own tree and each escaping target are placed at their repository-relative paths under one root, and the builder stage's working directory is the project's own path under it, so every relative path resolves there to what it resolves to on the host. Nothing in pyproject.toml or uv.lock is rewritten. Containment is the rule: a path leaving the repository, or naming a directory that is not there, is reported with the declaration rather than skipped, because a silently dropped source would build the image against a different dependency than the developer builds against. That build claims the whole tree it assembled (RECIPE_INVENTORY_SCOPE_TREE), which is what roots the docker context at the assembled output; a build that only renders a Dockerfile and its ignore still claims exactly those and still builds the service directory. Second, the builder stage ran whichever uv the base image had been built with, a version stated nowhere a project or this agent can read. A project written against a newer `[tool.uv]` key was diagnosed there as an unknown field — the base image carries uv 0.5.29, measured, and that uv rejects build-constraint-dependencies. A recipe meant to be durable and reproducible cannot leave its resolver implicit, and the Go recipe already states its Go and Alpine versions outright. The builder stage now copies a pinned uv in before anything resolves. Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5a35751 commit 22d4fc3

8 files changed

Lines changed: 918 additions & 14 deletions

File tree

‎AGENTS.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ anything from it.
112112
| `main.go` | settings, agent advertisement, composition of the generic Python layer |
113113
| `runtime.go` | Init/Start/Stop/Destroy, uvicorn supervision, hot reload, the lock order |
114114
| `builder.go` | `Create` scaffolding, OpenAPI endpoint loading, Docker + kustomize recipes |
115+
| `uvsources.go` | the assembled build context: `[tool.uv.sources]` paths leaving the project |
115116
| `probes.go`, `probe.py` | endpoint health translated to Kubernetes probes / a local probe |
116117
| `templates/` | **what ships** — embedded via `go:embed`, rendered on `Create` |
117118
| `base/` | a checked-in sample service, embedded nowhere |
@@ -135,6 +136,15 @@ into `templates/factory/code/pyproject.toml.tmpl` yourself.
135136
- **Readiness is the endpoint's declared health check, never an open port.**
136137
Probe intents (readiness / startup / liveness) stay separate through
137138
`resources.PlanEndpointProbes`; collapsing them silently drops a predicate.
139+
- **The build recipe owns its uv, and its build context.** The emitted
140+
Dockerfile copies a pinned uv (`uvVersion` in `main.go`) rather than running
141+
whichever one the base image was built with — a project written against a
142+
newer `[tool.uv]` key otherwise resolves locally and fails in the builder
143+
stage. And a `[tool.uv.sources]` path leaving the project makes the recipe
144+
assemble its own context, mirroring the repository-relative layout so the
145+
same relative path resolves there; that plan claims the whole tree
146+
(`RECIPE_INVENTORY_SCOPE_TREE`), the Dockerfile-only one claims just what it
147+
emitted. Never rewrite `pyproject.toml` or `uv.lock` to make a path resolve.
138148
- **The runtime image is pinned.** `docker-image` rejects a bare name and
139149
`:latest`. The default `codeflydev/python` companion is built by core and
140150
repinned on release — prefer it.

‎builder.go‎

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ type DockerTemplating struct {
167167
Components []string
168168
RuntimePackages []string
169169
Envs []Env
170+
171+
// UVImage is the pinned uv distribution the builder stage copies its uv
172+
// from, so the tool that reads pyproject.toml and uv.lock is a declared
173+
// input of the recipe rather than whatever the base image was built with.
174+
UVImage string
175+
176+
// ServicePrefix, ProjectWorkdir and Carried describe the build context.
177+
// They are empty / defaulted when the recipe builds the service directory
178+
// itself, which renders the Dockerfile this agent has always rendered.
179+
ServicePrefix string
180+
ProjectWorkdir string
181+
Carried []CarriedSource
170182
}
171183

172184
func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
@@ -183,19 +195,69 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
183195
return s.Base.Builder.BuildError(err)
184196
}
185197
ctx = s.Wool.Inject(ctx)
186-
docker := DockerTemplating{
187-
Builder: runtimeImage.FullName(),
188-
Components: requirements.All(),
189-
}
190198
outputDir := req.GetOutputDirectory()
191199
emitted, err := services.PrepareRecipeDestination(builderFS, outputDir)
192200
if err != nil {
193201
return s.Base.Builder.BuildError(err)
194202
}
203+
// A `[tool.uv.sources]` path resolves against the project directory, and the
204+
// default context is the service directory and nothing beside it: carry what
205+
// leaves the project into an assembled context so the builder stage resolves
206+
// what the host resolves. A service with no such path assembles nothing.
207+
assembled, err := assembleUVContext(s.Location, s.SourceLocation, outputDir)
208+
if err != nil {
209+
return s.Base.Builder.BuildError(err)
210+
}
211+
docker := DockerTemplating{
212+
Builder: runtimeImage.FullName(),
213+
Components: requirements.All(),
214+
UVImage: uvImage,
215+
ProjectWorkdir: defaultProjectWorkdir,
216+
}
217+
if assembled != nil {
218+
docker.ServicePrefix = assembled.ServicePrefix
219+
docker.ProjectWorkdir = assembled.ProjectWorkdir
220+
docker.Carried = assembled.Carried
221+
}
195222
if err := s.Base.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", outputDir)); err != nil {
196223
return s.Base.Builder.BuildError(err)
197224
}
198-
return s.Base.Builder.SingleImageBuildResponse(req, s.DockerImage(dockerRequest).FullName(), emitted)
225+
image := s.DockerImage(dockerRequest).FullName()
226+
if assembled == nil {
227+
// Only the Dockerfile and its ignore were written, and the application's
228+
// inputs are the service's own tree: the plan claims what it emitted and
229+
// the caller builds the service directory.
230+
return s.Base.Builder.SingleImageBuildResponse(req, image, emitted)
231+
}
232+
// The whole destination was assembled by this build, so the plan claims all
233+
// of it and the caller builds the assembled tree rather than the service
234+
// directory — which is what RECIPE_INVENTORY_SCOPE_TREE declares.
235+
plan, err := services.BuildDockerBuildPlan(outputDir, []*builderv0.DockerBuildRecipe{{
236+
Name: "app",
237+
Dockerfile: "Dockerfile",
238+
Context: ".",
239+
Dockerignore: dockerignoreOf(emitted),
240+
Image: image,
241+
Platforms: services.RecipeBuildPlatforms(),
242+
}})
243+
if err != nil {
244+
return s.Base.Builder.BuildError(err)
245+
}
246+
s.Base.Builder.WithBuildPlan(plan)
247+
return s.Base.Builder.BuildResponse()
248+
}
249+
250+
// dockerignoreOf names the ignore file only when this build rendered one.
251+
// Probing the destination instead would adopt a stale file: the executor copies
252+
// the referenced ignore to the path buildx discovers, so one left behind by an
253+
// older template set would silently keep files out of the image.
254+
func dockerignoreOf(emitted []string) string {
255+
for _, name := range emitted {
256+
if name == "dockerignore" {
257+
return name
258+
}
259+
}
260+
return ""
199261
}
200262

201263
// SBOM serves both evidence scopes. Source inventory stays with the generic

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.27.0
55
require (
66
github.com/codefly-dev/core v0.5.1
77
github.com/codefly-dev/service-python v0.0.66
8+
github.com/pelletier/go-toml/v2 v2.4.3
89
github.com/stretchr/testify v1.12.1
910
google.golang.org/grpc v1.83.2
1011
gopkg.in/yaml.v3 v3.0.1
@@ -92,7 +93,6 @@ require (
9293
github.com/oklog/ulid/v2 v2.1.2 // indirect
9394
github.com/opencontainers/go-digest v1.0.0 // indirect
9495
github.com/opencontainers/image-spec v1.1.1 // indirect
95-
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
9696
github.com/pjbgf/sha1cd v0.6.0 // indirect
9797
github.com/pkg/errors v0.9.1 // indirect
9898
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect

‎main.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ type Settings struct {
9494
// other codefly-built image.
9595
var runtimeImage = &resources.DockerImage{Name: "codeflydev/python", Tag: "0.0.1"}
9696

97+
// uvVersion is the uv the emitted build recipe resolves its lockfile with.
98+
//
99+
// The base image carries a uv of its own, installed when that image was built
100+
// and stated nowhere a project or this agent can read. A build recipe is meant
101+
// to be durable and reproducible, and a recipe whose resolver version is a
102+
// property of its base image is neither: a project written against a newer
103+
// `[tool.uv]` key resolves on the developer's machine and fails inside the
104+
// builder stage with a TOML parse error naming a field the older uv does not
105+
// know. Pinning it here is the same contract the Go recipe already keeps for
106+
// its own toolchain, which states its Go and Alpine versions outright.
107+
//
108+
// Bump it deliberately, together with uvImage.
109+
const uvVersion = "0.12.16"
110+
111+
// uvImage is the distribution the builder stage copies that uv from. astral
112+
// publishes each release as a multi-architecture image holding the static
113+
// binary, so the copy needs no package manager and no network beyond the
114+
// registry the build already talks to.
115+
const uvImage = "ghcr.io/astral-sh/uv:" + uvVersion
116+
97117
// Service is the FastAPI specialization. It embeds the generic Python
98118
// Service so methods defined on *pythonservice.Service (and transitively
99119
// *services.Base: Wool, Logger, Location, Identity, …) are promoted.

‎templates/builder/Dockerfile.tmpl‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
# The builder image — installs the venv via uv, then we copy it into the runtime.
22
FROM {{.Builder}} as builder
33

4-
WORKDIR /app
5-
6-
COPY code/pyproject.toml code/uv.lock ./
7-
8-
# Install deps to a project-local .venv. --frozen requires uv.lock to match
9-
# pyproject.toml; this catches drift at build time rather than at runtime.
4+
# uv is part of what the recipe has to state. A base image carries whichever uv
5+
# it happened to be built with, so a project written against a newer `[tool.uv]`
6+
# key resolves on the developer's machine and fails inside the builder stage
7+
# with a TOML parse error naming a field that uv there does not know. Copying a
8+
# pinned uv in makes the tool that reads pyproject.toml and uv.lock a declared
9+
# input of the recipe instead of a property of the base image.
10+
COPY --from={{.UVImage}} /uv /usr/local/bin/uv
11+
12+
WORKDIR {{.ProjectWorkdir}}
13+
14+
COPY {{.ServicePrefix}}code/pyproject.toml {{.ServicePrefix}}code/uv.lock ./
15+
{{- if .Carried}}
16+
17+
# A `[tool.uv.sources]` path leaving the project, placed at the same
18+
# repository-relative path the developer resolves it from, so the directive and
19+
# the lockfile entry that record it resolve here to what they resolve to there.
20+
{{- range .Carried}}
21+
COPY {{.Context}} {{.Image}}
22+
{{- end}}
23+
{{- end}}
24+
25+
# Install deps to a project-local .venv. --frozen installs the locked resolution
26+
# as it stands instead of re-resolving it at build time.
1027
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
1128
UV_LINK_MODE=copy
1229
RUN uv sync --frozen --no-dev --no-install-project
@@ -27,10 +44,10 @@ ENV VIRTUAL_ENV=/app/.venv \
2744

2845
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
2946

30-
COPY --chown=appuser service.codefly.yaml .
47+
COPY --chown=appuser {{.ServicePrefix}}service.codefly.yaml .
3148

3249
{{ range .Components}}
33-
COPY --chown=appuser {{.}} {{.}}
50+
COPY --chown=appuser {{$.ServicePrefix}}{{.}} {{.}}
3451
{{end}}
3552

3653
{{ range .Envs}}

0 commit comments

Comments
 (0)