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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ anything from it.
| `main.go` | settings, agent advertisement, composition of the generic Python layer |
| `runtime.go` | Init/Start/Stop/Destroy, uvicorn supervision, hot reload, the lock order |
| `builder.go` | `Create` scaffolding, OpenAPI endpoint loading, Docker + kustomize recipes |
| `uvsources.go` | the assembled build context: `[tool.uv.sources]` paths leaving the project |
| `probes.go`, `probe.py` | endpoint health translated to Kubernetes probes / a local probe |
| `templates/` | **what ships** — embedded via `go:embed`, rendered on `Create` |
| `base/` | a checked-in sample service, embedded nowhere |
Expand All @@ -135,6 +136,15 @@ into `templates/factory/code/pyproject.toml.tmpl` yourself.
- **Readiness is the endpoint's declared health check, never an open port.**
Probe intents (readiness / startup / liveness) stay separate through
`resources.PlanEndpointProbes`; collapsing them silently drops a predicate.
- **The build recipe owns its uv, and its build context.** The emitted
Dockerfile copies a pinned uv (`uvVersion` in `main.go`) rather than running
whichever one the base image was built with — a project written against a
newer `[tool.uv]` key otherwise resolves locally and fails in the builder
stage. And a `[tool.uv.sources]` path leaving the project makes the recipe
assemble its own context, mirroring the repository-relative layout so the
same relative path resolves there; that plan claims the whole tree
(`RECIPE_INVENTORY_SCOPE_TREE`), the Dockerfile-only one claims just what it
emitted. Never rewrite `pyproject.toml` or `uv.lock` to make a path resolve.
- **The runtime image is pinned.** `docker-image` rejects a bare name and
`:latest`. The default `codeflydev/python` companion is built by core and
repinned on release — prefer it.
Expand Down
72 changes: 67 additions & 5 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ type DockerTemplating struct {
Components []string
RuntimePackages []string
Envs []Env

// UVImage is the pinned uv distribution the builder stage copies its uv
// from, so the tool that reads pyproject.toml and uv.lock is a declared
// input of the recipe rather than whatever the base image was built with.
UVImage string

// ServicePrefix, ProjectWorkdir and Carried describe the build context.
// They are empty / defaulted when the recipe builds the service directory
// itself, which renders the Dockerfile this agent has always rendered.
ServicePrefix string
ProjectWorkdir string
Carried []CarriedSource
}

func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
Expand All @@ -183,19 +195,69 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
return s.Base.Builder.BuildError(err)
}
ctx = s.Wool.Inject(ctx)
docker := DockerTemplating{
Builder: runtimeImage.FullName(),
Components: requirements.All(),
}
outputDir := req.GetOutputDirectory()
emitted, err := services.PrepareRecipeDestination(builderFS, outputDir)
if err != nil {
return s.Base.Builder.BuildError(err)
}
// A `[tool.uv.sources]` path resolves against the project directory, and the
// default context is the service directory and nothing beside it: carry what
// leaves the project into an assembled context so the builder stage resolves
// what the host resolves. A service with no such path assembles nothing.
assembled, err := assembleUVContext(s.Location, s.SourceLocation, outputDir)
if err != nil {
return s.Base.Builder.BuildError(err)
}
docker := DockerTemplating{
Builder: runtimeImage.FullName(),
Components: requirements.All(),
UVImage: uvImage,
ProjectWorkdir: defaultProjectWorkdir,
}
if assembled != nil {
docker.ServicePrefix = assembled.ServicePrefix
docker.ProjectWorkdir = assembled.ProjectWorkdir
docker.Carried = assembled.Carried
}
if err := s.Base.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", outputDir)); err != nil {
return s.Base.Builder.BuildError(err)
}
return s.Base.Builder.SingleImageBuildResponse(req, s.DockerImage(dockerRequest).FullName(), emitted)
image := s.DockerImage(dockerRequest).FullName()
if assembled == nil {
// Only the Dockerfile and its ignore were written, and the application's
// inputs are the service's own tree: the plan claims what it emitted and
// the caller builds the service directory.
return s.Base.Builder.SingleImageBuildResponse(req, image, emitted)
}
// The whole destination was assembled by this build, so the plan claims all
// of it and the caller builds the assembled tree rather than the service
// directory — which is what RECIPE_INVENTORY_SCOPE_TREE declares.
plan, err := services.BuildDockerBuildPlan(outputDir, []*builderv0.DockerBuildRecipe{{
Name: "app",
Dockerfile: "Dockerfile",
Context: ".",
Dockerignore: dockerignoreOf(emitted),
Image: image,
Platforms: services.RecipeBuildPlatforms(),
}})
if err != nil {
return s.Base.Builder.BuildError(err)
}
s.Base.Builder.WithBuildPlan(plan)
return s.Base.Builder.BuildResponse()
}

// dockerignoreOf names the ignore file only when this build rendered one.
// Probing the destination instead would adopt a stale file: the executor copies
// the referenced ignore to the path buildx discovers, so one left behind by an
// older template set would silently keep files out of the image.
func dockerignoreOf(emitted []string) string {
for _, name := range emitted {
if name == "dockerignore" {
return name
}
}
return ""
}

// SBOM serves both evidence scopes. Source inventory stays with the generic
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.27.0
require (
github.com/codefly-dev/core v0.5.1
github.com/codefly-dev/service-python v0.0.66
github.com/pelletier/go-toml/v2 v2.4.3
github.com/stretchr/testify v1.12.1
google.golang.org/grpc v1.83.2
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -92,7 +93,6 @@ require (
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
Expand Down
20 changes: 20 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ type Settings struct {
// other codefly-built image.
var runtimeImage = &resources.DockerImage{Name: "codeflydev/python", Tag: "0.0.1"}

// uvVersion is the uv the emitted build recipe resolves its lockfile with.
//
// The base image carries a uv of its own, installed when that image was built
// and stated nowhere a project or this agent can read. A build recipe is meant
// to be durable and reproducible, and a recipe whose resolver version is a
// property of its base image is neither: a project written against a newer
// `[tool.uv]` key resolves on the developer's machine and fails inside the
// builder stage with a TOML parse error naming a field the older uv does not
// know. Pinning it here is the same contract the Go recipe already keeps for
// its own toolchain, which states its Go and Alpine versions outright.
//
// Bump it deliberately, together with uvImage.
const uvVersion = "0.12.16"

// uvImage is the distribution the builder stage copies that uv from. astral
// publishes each release as a multi-architecture image holding the static
// binary, so the copy needs no package manager and no network beyond the
// registry the build already talks to.
const uvImage = "ghcr.io/astral-sh/uv:" + uvVersion

// Service is the FastAPI specialization. It embeds the generic Python
// Service so methods defined on *pythonservice.Service (and transitively
// *services.Base: Wool, Logger, Location, Identity, …) are promoted.
Expand Down
33 changes: 25 additions & 8 deletions templates/builder/Dockerfile.tmpl
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# The builder image — installs the venv via uv, then we copy it into the runtime.
FROM {{.Builder}} as builder

WORKDIR /app

COPY code/pyproject.toml code/uv.lock ./

# Install deps to a project-local .venv. --frozen requires uv.lock to match
# pyproject.toml; this catches drift at build time rather than at runtime.
# uv is part of what the recipe has to state. A base image carries whichever uv
# it happened to be built with, so a project written against a newer `[tool.uv]`
# key resolves on the developer's machine and fails inside the builder stage
# with a TOML parse error naming a field that uv there does not know. Copying a
# pinned uv in makes the tool that reads pyproject.toml and uv.lock a declared
# input of the recipe instead of a property of the base image.
COPY --from={{.UVImage}} /uv /usr/local/bin/uv

WORKDIR {{.ProjectWorkdir}}

COPY {{.ServicePrefix}}code/pyproject.toml {{.ServicePrefix}}code/uv.lock ./
{{- if .Carried}}

# A `[tool.uv.sources]` path leaving the project, placed at the same
# repository-relative path the developer resolves it from, so the directive and
# the lockfile entry that record it resolve here to what they resolve to there.
{{- range .Carried}}
COPY {{.Context}} {{.Image}}
{{- end}}
{{- end}}

# Install deps to a project-local .venv. --frozen installs the locked resolution
# as it stands instead of re-resolving it at build time.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy
RUN uv sync --frozen --no-dev --no-install-project
Expand All @@ -27,10 +44,10 @@ ENV VIRTUAL_ENV=/app/.venv \

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

COPY --chown=appuser service.codefly.yaml .
COPY --chown=appuser {{.ServicePrefix}}service.codefly.yaml .

{{ range .Components}}
COPY --chown=appuser {{.}} {{.}}
COPY --chown=appuser {{$.ServicePrefix}}{{.}} {{.}}
{{end}}

{{ range .Envs}}
Expand Down
Loading
Loading