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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ concurrency:

jobs:
ci:
uses: codefly-dev/core/.github/workflows/go-service-ci.yml@v0.19.4
uses: codefly-dev/core/.github/workflows/go-service-ci.yml@v0.19.6
with:
# The Python runtime tests drive `uv` on the host (dependency sync,
# pytest); it isn't on the runner by default.
Expand Down
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ within reach.
## Build and test

Derived from `.github/workflows/ci.yml`, which delegates to core's shared
`go-service-ci.yml`, pinned to the linked core's tag (`@v0.19.4`; move it with
`go-service-ci.yml`, pinned to the linked core's tag (`@v0.19.6`; move it with
the core bump), and adds a `race` job of its own. Go comes from `go.mod`
(1.27.2). All of it runs locally:
(1.27.3). All of it runs locally:

```bash
go build ./... # CI: build job
Expand Down Expand Up @@ -126,6 +126,7 @@ anything from it.
| `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 |
| `uvgitsources.go` | the git commits `uv.lock` pins, declared for the caller to mirror into the build |
| `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 Down Expand Up @@ -158,6 +159,13 @@ into `templates/factory/code/pyproject.toml.tmpl` yourself.
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.
A git source is declared in the recipe (`git_source_downloads`) by the URL
exactly as `uv.lock` records it up to its query, which is what uv hands git;
the caller fetches it with its own credentials and the Dockerfile maps that
URL onto the mirror; a normalized URL would miss it silently. A source core
refuses, or whose URL begins an undeclared locked one (insteadOf is a
prefix match), is not declared; a warning says why. An image whose own git
config already rewrites a declared URL keeps that rewrite (git prefers it).
- **The runtime image is pinned.** `docker-image` rejects a bare name and
`:latest`. The default `ghcr.io/codefly-dev/python` companion is built from
core and pinned by the digest of its multi-platform index — prefer it. Its tag
Expand Down
27 changes: 24 additions & 3 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ type DockerTemplating struct {
// IgnorePatterns are the dockerignore entries of a recipe that copies the
// service tree whole.
IgnorePatterns []string
// GitSources are the repositories of the git commits the lock pins, which
// the builder stage reads from the caller's mirrors (uvgitsources.go).
GitSources []uvGitMirror
}

func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
Expand Down Expand Up @@ -248,11 +251,25 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
if err != nil {
return s.Base.Builder.BuildError(err)
}
gitSources, unmirrored, err := lockedGitSources(uvLockPath(s.Location, s.SourceLocation, membership))
if err != nil {
return s.Base.Builder.BuildError(err)
}
for _, source := range unmirrored {
// The URL is not logged: a lock may still carry a credential in it.
s.Wool.Warn("git source is not mirrored: the image build fetches it itself",
wool.Field("package", source.Package), wool.Field("reason", source.Reason))
}
var options []services.RecipeOption
if len(gitSources) > 0 {
options = append(options, services.WithGitSourceDownloads(gitSources...))
}
docker := DockerTemplating{
Builder: runtimeImage.FullName(),
Components: components,
UVImage: uvImage,
ProjectWorkdir: defaultProjectWorkdir,
GitSources: uvGitMirrors(gitSources),
}
if assembled != nil {
docker.ServicePrefix = assembled.ServicePrefix
Expand All @@ -272,19 +289,23 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
// 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)
return s.Base.Builder.SingleImageBuildResponse(req, image, emitted, options...)
}
// 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{{
recipe := &builderv0.DockerBuildRecipe{
Name: "app",
Dockerfile: "Dockerfile",
Context: ".",
Dockerignore: dockerignoreOf(emitted),
Image: image,
Platforms: services.RecipeBuildPlatforms(),
}})
}
for _, option := range options {
option(recipe)
}
plan, err := services.BuildDockerBuildPlan(outputDir, []*builderv0.DockerBuildRecipe{recipe})
if err != nil {
return s.Base.Builder.BuildError(err)
}
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module github.com/codefly-dev/service-python-fastapi

go 1.27.2
go 1.27.3

require (
github.com/codefly-dev/core v0.19.4
github.com/codefly-dev/core v0.19.6
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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/codefly-dev/core v0.19.4 h1:vUvaFrWkB8B3ksQY/qlgBTVj3jMNhdEpJca8aofaals=
github.com/codefly-dev/core v0.19.4/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI=
github.com/codefly-dev/core v0.19.6 h1:ZkCChK/BioK+B7zZ0fp+/rXDCy0k+lsdFqHjyc5xFIg=
github.com/codefly-dev/core v0.19.6/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A=
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
github.com/codefly-dev/service-python v0.0.66 h1:Y74QvKAYMaOnZcsfRxuD9dpQo3//7Mjo12RYELyrp5o=
Expand Down
33 changes: 33 additions & 0 deletions templates/builder/Dockerfile.tmpl
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
{{if .GitSources -}}
# syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
# The git commits uv.lock pins (git_source_downloads in the recipe), fetched by
# the caller on its own machine, with its own credentials, before any image
# build, and supplied here as the build context of this name: one bare mirror
# per repository. Without a caller supplying it, the stage is empty and uv
# fetches them from the network as it always did.
FROM scratch AS uvsources

{{end -}}
# The builder image — installs the venv via uv, then we copy it into the runtime.
FROM {{.Builder}} as builder

Expand Down Expand Up @@ -43,7 +53,30 @@ COPY {{.Context}} {{.Image}}
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=/opt/uv/python
{{- if .GitSources}}
#
# With the caller's mirrors, git reads each locked repository from its mirror
# instead of the URL the lock records (uv hands git that URL unchanged), so no
# credential is needed, and a commit missing from the mirror fails here rather
# than prompting for one. The mappings are appended after the git configuration
# the environment already carries, when its GIT_CONFIG_COUNT is a plain count;
# otherwise the configuration is left alone and the sources are fetched as
# without a mirror. An image whose own git configuration (system, global or
# environment) already rewrites a declared repository's URL keeps its rewrite:
# git prefers it, and the build fetches that source as it does without a mirror.
RUN --mount=type=bind,from=uvsources,target=/uvsources \
if [ -n "$(ls -A /uvsources)" ]; then \
n=${GIT_CONFIG_COUNT:-0}; n=${n#+}; \
case $n in ''|*[!0-9]*) n= ;; *) while [ "${n#0}" != "$n" ] && [ -n "${n#0}" ]; do n=${n#0}; done; [ ${#n} -le 4 ] || n= ;; esac; \
if [ -z "$n" ]; then echo "inherited GIT_CONFIG_COUNT is not a plain count; git sources are fetched without the mirror"; \
else export GIT_TERMINAL_PROMPT=0{{range $i, $source := .GitSources}} \
"GIT_CONFIG_KEY_$((n+{{$i}}))=url.file:///uvsources/{{$source.Path}}.insteadOf" "GIT_CONFIG_VALUE_$((n+{{$i}}))="'{{$source.Repository}}'{{end}} \
GIT_CONFIG_COUNT=$((n+{{len .GitSources}})); fi; \
fi && \
mkdir -p /opt/uv/python && uv sync --frozen --no-dev --no-install-project --no-editable
{{- else}}
RUN mkdir -p /opt/uv/python && uv sync --frozen --no-dev --no-install-project --no-editable
{{- end}}


# Runtime
Expand Down
90 changes: 90 additions & 0 deletions testdata/dockerfile/git-sources.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
# The git commits uv.lock pins (git_source_downloads in the recipe), fetched by
# the caller on its own machine, with its own credentials, before any image
# build, and supplied here as the build context of this name: one bare mirror
# per repository. Without a caller supplying it, the stage is empty and uv
# fetches them from the network as it always did.
FROM scratch AS uvsources

# The builder image — installs the venv via uv, then we copy it into the runtime.
FROM BUILDER_IMAGE as builder

# 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=UV_IMAGE /uv /usr/local/bin/uv

WORKDIR /app

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

# Install deps to a project-local .venv. --frozen installs the locked resolution
# as it stands instead of re-resolving it at build time. --no-editable installs
# workspace members and path sources as packages in the venv rather than links
# to builder-stage sources, which the runtime stage does not carry.
#
# A project whose requires-python the base image's Python does not satisfy gets
# the interpreter uv installs for it, as on the host. It is installed where the
# runtime stage copies it from, so the venv's interpreter exists there too.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=/opt/uv/python
#
# With the caller's mirrors, git reads each locked repository from its mirror
# instead of the URL the lock records (uv hands git that URL unchanged), so no
# credential is needed, and a commit missing from the mirror fails here rather
# than prompting for one. The mappings are appended after the git configuration
# the environment already carries, when its GIT_CONFIG_COUNT is a plain count;
# otherwise the configuration is left alone and the sources are fetched as
# without a mirror. An image whose own git configuration (system, global or
# environment) already rewrites a declared repository's URL keeps its rewrite:
# git prefers it, and the build fetches that source as it does without a mirror.
RUN --mount=type=bind,from=uvsources,target=/uvsources \
if [ -n "$(ls -A /uvsources)" ]; then \
n=${GIT_CONFIG_COUNT:-0}; n=${n#+}; \
case $n in ''|*[!0-9]*) n= ;; *) while [ "${n#0}" != "$n" ] && [ -n "${n#0}" ]; do n=${n#0}; done; [ ${#n} -le 4 ] || n= ;; esac; \
if [ -z "$n" ]; then echo "inherited GIT_CONFIG_COUNT is not a plain count; git sources are fetched without the mirror"; \
else export GIT_TERMINAL_PROMPT=0 \
"GIT_CONFIG_KEY_$((n+0))=url.file:///uvsources/c9fa8363b851c2643d648edad1c8c7892904f717385511fe4771422d0817a55e.git.insteadOf" "GIT_CONFIG_VALUE_$((n+0))="'https://git.example.invalid/acme/mono' \
"GIT_CONFIG_KEY_$((n+1))=url.file:///uvsources/d2b987abe743521016c33e82dba28324380629b9d2544f9e5213ee4299a3af6c.git.insteadOf" "GIT_CONFIG_VALUE_$((n+1))="'ssh://git@git.example.invalid:2222/acme/tools.git' \
GIT_CONFIG_COUNT=$((n+2)); fi; \
fi && \
mkdir -p /opt/uv/python && uv sync --frozen --no-dev --no-install-project --no-editable


# Runtime
FROM BUILDER_IMAGE as runtime

WORKDIR /app

# UID pinned to match runAsUser in the kustomize Deployment: Kubernetes'
# runAsNonRoot check rejects a container whose image user is non-numeric.
RUN adduser -D -u 1000 appuser
USER appuser

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder /opt/uv/python /opt/uv/python
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

COPY --chown=appuser service.codefly.yaml .


COPY --chown=appuser code/src code/src




WORKDIR /app/code

# uvicorn's logging configuration and the filter it names (uvicorn_logging.go):
# health probes are dropped from the access log, everything else is as uvicorn
# writes it. The builder writes both into the project before the build.
COPY --chown=appuser code/uvicorn-log-config.json code/codefly_uvicorn_logging.py ./

# Default REST port (overridden by codefly at runtime).
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080", "--log-config", "uvicorn-log-config.json"]
62 changes: 62 additions & 0 deletions testdata/dockerfile/no-git-sources.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# The builder image — installs the venv via uv, then we copy it into the runtime.
FROM BUILDER_IMAGE as builder

# 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=UV_IMAGE /uv /usr/local/bin/uv

WORKDIR /app

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

# Install deps to a project-local .venv. --frozen installs the locked resolution
# as it stands instead of re-resolving it at build time. --no-editable installs
# workspace members and path sources as packages in the venv rather than links
# to builder-stage sources, which the runtime stage does not carry.
#
# A project whose requires-python the base image's Python does not satisfy gets
# the interpreter uv installs for it, as on the host. It is installed where the
# runtime stage copies it from, so the venv's interpreter exists there too.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=/opt/uv/python
RUN mkdir -p /opt/uv/python && uv sync --frozen --no-dev --no-install-project --no-editable


# Runtime
FROM BUILDER_IMAGE as runtime

WORKDIR /app

# UID pinned to match runAsUser in the kustomize Deployment: Kubernetes'
# runAsNonRoot check rejects a container whose image user is non-numeric.
RUN adduser -D -u 1000 appuser
USER appuser

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder /opt/uv/python /opt/uv/python
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

COPY --chown=appuser service.codefly.yaml .


COPY --chown=appuser code/src code/src




WORKDIR /app/code

# uvicorn's logging configuration and the filter it names (uvicorn_logging.go):
# health probes are dropped from the access log, everything else is as uvicorn
# writes it. The builder writes both into the project before the build.
COPY --chown=appuser code/uvicorn-log-config.json code/codefly_uvicorn_logging.py ./

# Default REST port (overridden by codefly at runtime).
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080", "--log-config", "uvicorn-log-config.json"]
Loading
Loading