diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 55a87f74f6b..0b9328e4878 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -398,6 +398,10 @@ private static IResourceBuilder AddPythonAppCore( _ => throw new InvalidOperationException($"Unsupported entrypoint type: {entrypointType}") }; + // Check if uv.lock exists in the working directory + var uvLockPath = Path.Combine(resource.WorkingDirectory, "uv.lock"); + var hasUvLock = File.Exists(uvLockPath); + var builderStage = context.Builder .From($"ghcr.io/astral-sh/uv:python{pythonVersion}-bookworm-slim", "builder") .EmptyLine() @@ -406,20 +410,45 @@ private static IResourceBuilder AddPythonAppCore( .Env("UV_LINK_MODE", "copy") .EmptyLine() .WorkDir("/app") - .EmptyLine() - .Comment("Install dependencies first for better layer caching") - .Comment("Uses BuildKit cache mounts to speed up repeated builds") - .RunWithMounts( - "uv sync --locked --no-install-project --no-dev", - "type=cache,target=/root/.cache/uv", - "type=bind,source=uv.lock,target=uv.lock", - "type=bind,source=pyproject.toml,target=pyproject.toml") - .EmptyLine() - .Comment("Copy the rest of the application source and install the project") - .Copy(".", "/app") - .RunWithMounts( - "uv sync --locked --no-dev", - "type=cache,target=/root/.cache/uv"); + .EmptyLine(); + + if (hasUvLock) + { + // If uv.lock exists, use locked mode for reproducible builds + builderStage + .Comment("Install dependencies first for better layer caching") + .Comment("Uses BuildKit cache mounts to speed up repeated builds") + .RunWithMounts( + "uv sync --locked --no-install-project --no-dev", + "type=cache,target=/root/.cache/uv", + "type=bind,source=uv.lock,target=uv.lock", + "type=bind,source=pyproject.toml,target=pyproject.toml") + .EmptyLine() + .Comment("Copy the rest of the application source and install the project") + .Copy(".", "/app") + .RunWithMounts( + "uv sync --locked --no-dev", + "type=cache,target=/root/.cache/uv"); + } + else + { + // If uv.lock doesn't exist, copy pyproject.toml and generate lock file + builderStage + .Comment("Copy pyproject.toml to install dependencies") + .Copy("pyproject.toml", "/app/") + .EmptyLine() + .Comment("Install dependencies and generate lock file") + .Comment("Uses BuildKit cache mount to speed up repeated builds") + .RunWithMounts( + "uv sync --no-install-project --no-dev", + "type=cache,target=/root/.cache/uv") + .EmptyLine() + .Comment("Copy the rest of the application source and install the project") + .Copy(".", "/app") + .RunWithMounts( + "uv sync --no-dev", + "type=cache,target=/root/.cache/uv"); + } var runtimeBuilder = context.Builder .From($"python:{pythonVersion}-slim-bookworm", "app") diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index c5b76246e67..fbf4d2e3a44 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -999,6 +999,76 @@ await Verify(scriptDockerfileContent) .AppendContentAsFile(executableDockerfileContent); } + [Fact] + public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock() + { + using var sourceDir = new TempDirectory(); + using var outputDir = new TempDirectory(); + var projectDirectory = sourceDir.Path; + + // Create a UV-based Python project with pyproject.toml but NO uv.lock + var pyprojectContent = """ + [project] + name = "test-app" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """; + + var scriptContent = """ + print("Hello from UV project!") + """; + + File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent); + // Note: NO uv.lock file created + File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent); + + var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "manifest", outputPath: outputDir.Path); + + // Add Python resources with different entrypoint types + builder.AddPythonScript("script-app", projectDirectory, "main.py") + .WithUvEnvironment(); + + builder.AddPythonModule("module-app", projectDirectory, "mymodule") + .WithUvEnvironment(); + + builder.AddPythonExecutable("executable-app", projectDirectory, "pytest") + .WithUvEnvironment(); + + var app = builder.Build(); + + app.Run(); + + // Verify that Dockerfiles were generated for each entrypoint type + var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile"); + Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint"); + + var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile"); + Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint"); + + var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile"); + Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint"); + + var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath); + var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath); + var executableDockerfileContent = File.ReadAllText(executableDockerfilePath); + + // Verify the Dockerfiles don't use --locked flag + Assert.DoesNotContain("--locked", scriptDockerfileContent); + Assert.DoesNotContain("--locked", moduleDockerfileContent); + Assert.DoesNotContain("--locked", executableDockerfileContent); + + await Verify(scriptDockerfileContent) + .AppendContentAsFile(moduleDockerfileContent) + .AppendContentAsFile(executableDockerfileContent); + } + [Fact] public async Task WithVSCodeDebugSupport_RemovesScriptArgumentForScriptEntrypoint() { diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt new file mode 100644 index 00000000000..d2d7e55090c --- /dev/null +++ b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt @@ -0,0 +1,46 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +# Enable bytecode compilation and copy mode for the virtual environment +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Copy pyproject.toml to install dependencies +COPY pyproject.toml /app/ + +# Install dependencies and generate lock file +# Uses BuildKit cache mount to speed up repeated builds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the rest of the application source and install the project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +FROM python:3.12-slim-bookworm AS app + +# ------------------------------ +# 🚀 Runtime stage +# ------------------------------ +# Create non-root user for security +RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser + +# Copy the application and virtual environment from builder +COPY --from=builder --chown=appuser:appuser /app /app + +# Add virtual environment to PATH and set VIRTUAL_ENV +ENV PATH=/app/.venv/bin:${PATH} +ENV VIRTUAL_ENV=/app/.venv +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Use the non-root user to run the application +USER appuser + +# Set working directory +WORKDIR /app + +# Run the application +ENTRYPOINT ["python","main.py"] diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt new file mode 100644 index 00000000000..8465ba07d31 --- /dev/null +++ b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt @@ -0,0 +1,46 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +# Enable bytecode compilation and copy mode for the virtual environment +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Copy pyproject.toml to install dependencies +COPY pyproject.toml /app/ + +# Install dependencies and generate lock file +# Uses BuildKit cache mount to speed up repeated builds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the rest of the application source and install the project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +FROM python:3.12-slim-bookworm AS app + +# ------------------------------ +# 🚀 Runtime stage +# ------------------------------ +# Create non-root user for security +RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser + +# Copy the application and virtual environment from builder +COPY --from=builder --chown=appuser:appuser /app /app + +# Add virtual environment to PATH and set VIRTUAL_ENV +ENV PATH=/app/.venv/bin:${PATH} +ENV VIRTUAL_ENV=/app/.venv +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Use the non-root user to run the application +USER appuser + +# Set working directory +WORKDIR /app + +# Run the application +ENTRYPOINT ["python","-m","mymodule"] diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt new file mode 100644 index 00000000000..16b9f185107 --- /dev/null +++ b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt @@ -0,0 +1,46 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +# Enable bytecode compilation and copy mode for the virtual environment +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Copy pyproject.toml to install dependencies +COPY pyproject.toml /app/ + +# Install dependencies and generate lock file +# Uses BuildKit cache mount to speed up repeated builds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the rest of the application source and install the project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +FROM python:3.12-slim-bookworm AS app + +# ------------------------------ +# 🚀 Runtime stage +# ------------------------------ +# Create non-root user for security +RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser + +# Copy the application and virtual environment from builder +COPY --from=builder --chown=appuser:appuser /app /app + +# Add virtual environment to PATH and set VIRTUAL_ENV +ENV PATH=/app/.venv/bin:${PATH} +ENV VIRTUAL_ENV=/app/.venv +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Use the non-root user to run the application +USER appuser + +# Set working directory +WORKDIR /app + +# Run the application +ENTRYPOINT ["pytest"]