diff --git a/.dockerignore b/.dockerignore index ecd8da4da0..14a939b671 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,36 @@ +.git/ +.gitmodules +.gitattributes +.editorconfig .cdidx/ .github/ +.agents/ +.agent_harness/ +.claude/ +.codex/ .vs/ .vscode/ +.idea/ TestResults/ **/bin/ **/obj/ **/.DS_Store *.user + +tests/ +tools/ +docs/ +changelog.d/ +CodeIndex.sln +CHANGELOG.md +README.md +AGENTS.md +AGENT_GUIDE.md +CLAUDE.md +DEVELOPER_GUIDE.md +SELF_IMPROVEMENT.md +TESTING_GUIDE.md +*.md +!COMMERCIAL_LICENSE.md +!INTEGRATION_POLICY.md +!TRADEMARKS.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caa45da949..540adcae42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1085,6 +1085,18 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" + - name: Extract container build metadata + id: container-metadata + run: | + set -euo pipefail + echo "commit=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + echo "date=$(git show -s --format=%cd --date=format:%Y-%m-%d HEAD)" >> "$GITHUB_OUTPUT" + if git diff-index --quiet HEAD -- ":/" ":(top,exclude,glob)**/packages.lock.json"; then + echo "dirty=clean" >> "$GITHUB_OUTPUT" + else + echo "dirty=dirty" >> "$GITHUB_OUTPUT" + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 @@ -1104,6 +1116,10 @@ jobs: provenance: mode=max sbom: true tags: ${{ steps.image-tags.outputs.tags }} + build-args: | + CDIDX_BUILD_COMMIT=${{ steps.container-metadata.outputs.commit }} + CDIDX_BUILD_DATE=${{ steps.container-metadata.outputs.date }} + CDIDX_BUILD_DIRTY=${{ steps.container-metadata.outputs.dirty }} publish-homebrew: if: github.repository == 'Widthdom/CodeIndex' diff --git a/Dockerfile b/Dockerfile index 94660048d4..8d9fef50d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,45 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +# Base image digests are multi-arch manifest list digests. Refresh with: +# docker buildx imagetools inspect mcr.microsoft.com/dotnet/:8.0-alpine +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:d9f4f4a5d99a43799b500ee1365c370e3233822fbe7d43666715d9b5b5cda2ab AS build WORKDIR /src -COPY . . +COPY Directory.Build.props nuget.config version.json ./ +COPY src/CodeIndex/CodeIndex.csproj src/CodeIndex/packages.lock.json src/CodeIndex/ +RUN dotnet restore src/CodeIndex/CodeIndex.csproj +COPY src/CodeIndex/ src/CodeIndex/ +COPY LICENSE COMMERCIAL_LICENSE.md INTEGRATION_POLICY.md TRADEMARKS.md ./ +COPY LICENSES/ LICENSES/ ARG TARGETARCH=amd64 -RUN dotnet restore src/CodeIndex/CodeIndex.csproj +ARG CDIDX_BUILD_COMMIT=unknown +ARG CDIDX_BUILD_DATE +ARG CDIDX_BUILD_DIRTY=unknown RUN case "$TARGETARCH" in \ amd64) rid="linux-musl-x64" ;; \ arm64) rid="linux-musl-arm64" ;; \ *) echo "Unsupported container architecture: $TARGETARCH" >&2; exit 1 ;; \ esac && \ + build_date="${CDIDX_BUILD_DATE:-$(date -u +%Y-%m-%d)}" && \ dotnet publish src/CodeIndex/CodeIndex.csproj \ --configuration Release \ --runtime "$rid" \ --self-contained true \ -p:PublishSingleFile=true \ -p:PublishTrimmed=true \ + -p:CdidxBuildCommitOverride="$CDIDX_BUILD_COMMIT" \ + -p:CdidxBuildDateOverride="$build_date" \ + -p:CdidxBuildDirtyOverride="$CDIDX_BUILD_DIRTY" \ --output /out -FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS runtime +FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine@sha256:7ec14bf41e70f3ca60f7b369b077636f642a0e6867caf28677d970e0abd9c6e6 AS runtime -RUN apk add --no-cache ca-certificates +COPY scripts/docker-entrypoint.sh /usr/local/bin/cdidx-entrypoint +RUN apk add --no-cache ca-certificates su-exec \ + && addgroup -S -g 10001 cdidx \ + && adduser -S -D -H -u 10001 -G cdidx -h /repo cdidx \ + && mkdir -p /repo \ + && chown cdidx:cdidx /repo \ + && chmod 0755 /usr/local/bin/cdidx-entrypoint WORKDIR /repo COPY --from=build /out/ /usr/local/lib/cdidx/ @@ -28,5 +47,5 @@ COPY LICENSE COMMERCIAL_LICENSE.md INTEGRATION_POLICY.md TRADEMARKS.md /usr/loca COPY LICENSES/ /usr/local/lib/cdidx/LICENSES/ RUN ln -s /usr/local/lib/cdidx/cdidx /usr/local/bin/cdidx -ENTRYPOINT ["cdidx"] +ENTRYPOINT ["/usr/local/bin/cdidx-entrypoint"] CMD ["--help"] diff --git a/changelog.d/unreleased/3492.fixed.md b/changelog.d/unreleased/3492.fixed.md new file mode 100644 index 0000000000..6d09ade3c1 --- /dev/null +++ b/changelog.d/unreleased/3492.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3492 +affected: + - Dockerfile + - scripts/docker-entrypoint.sh + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +--- + +## English + +- **Docker images now pin .NET base images and drop privileges for `cdidx` (#3492)** — the container build uses explicit multi-arch MCR image digests, creates a dedicated `cdidx` UID, and starts through an entrypoint that runs `cdidx` as the `/repo` owner or the dedicated UID so bind-mounted repositories remain writable without keeping the CLI process as root. + +## 日本語 + +- **Docker image が .NET base image を digest 固定し、`cdidx` 実行時に権限降格するようになりました (#3492)** — container build は MCR の multi-arch image digest を明示し、専用の `cdidx` UID を作成します。entrypoint は bind mount した repository を書き込めるよう、`/repo` の owner または専用 UID で `cdidx` を実行し、CLI process を root のままにしません。 diff --git a/changelog.d/unreleased/3493.fixed.md b/changelog.d/unreleased/3493.fixed.md new file mode 100644 index 0000000000..f74bea4fe4 --- /dev/null +++ b/changelog.d/unreleased/3493.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3493 +affected: + - .dockerignore + - .github/workflows/release.yml + - Dockerfile + - src/CodeIndex/CodeIndex.csproj + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +--- + +## English + +- **Docker builds now use a narrower context (#3493)** — the build stage copies only restore, source, version, and license inputs needed for the runtime image, while `.dockerignore` excludes VCS metadata, tests, docs, workflow files, changelog fragments, tools, and agent artifacts from the build context. Release builds pass commit, date, and dirty metadata through explicit build args so the image keeps the same version provenance without shipping `.git/`. + +## 日本語 + +- **Docker build context を絞り込みました (#3493)** — build stage は runtime image に必要な restore / source / version / license 入力だけをコピーし、`.dockerignore` は VCS metadata、tests、docs、workflow files、changelog fragments、tools、agent artifacts を build context から除外します。release build は commit/date/dirty metadata を明示的な build args で渡すため、`.git/` を含めなくても image の version provenance を維持します。 diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000000..1ae5b76f36 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -eq 0 ]; then + target_uid="${CDIDX_RUN_UID:-}" + target_gid="${CDIDX_RUN_GID:-}" + + if [ -z "$target_uid" ] && [ -e /repo ]; then + target_uid="$(stat -c '%u' /repo)" + fi + if [ -z "$target_gid" ] && [ -e /repo ]; then + target_gid="$(stat -c '%g' /repo)" + fi + + target_uid="${target_uid:-10001}" + target_gid="${target_gid:-10001}" + + if [ "$target_uid" != "0" ]; then + export HOME=/repo + exec su-exec "${target_uid}:${target_gid}" cdidx "$@" + fi +fi + +exec cdidx "$@" diff --git a/src/CodeIndex/CodeIndex.csproj b/src/CodeIndex/CodeIndex.csproj index 9271e1e931..3c39fc3389 100644 --- a/src/CodeIndex/CodeIndex.csproj +++ b/src/CodeIndex/CodeIndex.csproj @@ -89,6 +89,7 @@ --> - $(CdidxGitDateRaw) + $(CdidxBuildDateOverride) + $(CdidxGitDateRaw) $([System.DateTime]::UtcNow.ToString('yyyy-MM-dd')) - $(CdidxGitShaRaw) + $(CdidxBuildCommitOverride) + $(CdidxGitShaRaw) unknown - clean - dirty + $(CdidxBuildDirtyOverride) + clean + dirty unknown diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index bcfc7efc2f..df7115bf6a 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -467,6 +467,8 @@ public void ReleaseWorkflow_PublishesOfficialContainerImage() var root = GetRepositoryRoot(); var workflow = File.ReadAllText(Path.Combine(root, ".github", "workflows", "release.yml")); var dockerfile = File.ReadAllText(Path.Combine(root, "Dockerfile")); + var dockerignore = File.ReadAllText(Path.Combine(root, ".dockerignore")); + var entrypoint = File.ReadAllText(Path.Combine(root, "scripts", "docker-entrypoint.sh")); var project = File.ReadAllText(Path.Combine(root, "src", "CodeIndex", "CodeIndex.csproj")); Assert.Contains("publish-container:", workflow); @@ -479,12 +481,49 @@ public void ReleaseWorkflow_PublishesOfficialContainerImage() Assert.Contains("ghcr.io/widthdom/codeindex:${version}", workflow); Assert.Contains("ghcr.io/widthdom/codeindex:latest", workflow); Assert.Contains("tags: ${{ steps.image-tags.outputs.tags }}", workflow); + Assert.Contains("Extract container build metadata", workflow); + Assert.Contains("git rev-parse --short=7 HEAD", workflow); + Assert.Contains("git show -s --format=%cd --date=format:%Y-%m-%d HEAD", workflow); + Assert.Contains("CDIDX_BUILD_COMMIT=${{ steps.container-metadata.outputs.commit }}", workflow); + Assert.Contains("CDIDX_BUILD_DATE=${{ steps.container-metadata.outputs.date }}", workflow); + Assert.Contains("CDIDX_BUILD_DIRTY=${{ steps.container-metadata.outputs.dirty }}", workflow); Assert.Contains("*-*) ;;", workflow); - Assert.Contains("FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build", dockerfile); + Assert.Contains("docker buildx imagetools inspect mcr.microsoft.com/dotnet/:8.0-alpine", dockerfile); + Assert.Contains("FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine@sha256:d9f4f4a5d99a43799b500ee1365c370e3233822fbe7d43666715d9b5b5cda2ab AS build", dockerfile); + Assert.Contains("FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine@sha256:7ec14bf41e70f3ca60f7b369b077636f642a0e6867caf28677d970e0abd9c6e6 AS runtime", dockerfile); + Assert.DoesNotContain("FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build", dockerfile); + Assert.DoesNotContain("FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS runtime", dockerfile); + Assert.Contains("COPY scripts/docker-entrypoint.sh /usr/local/bin/cdidx-entrypoint", dockerfile); + Assert.Contains("apk add --no-cache ca-certificates su-exec", dockerfile); + Assert.Contains("addgroup -S -g 10001 cdidx", dockerfile); + Assert.Contains("adduser -S -D -H -u 10001 -G cdidx -h /repo cdidx", dockerfile); + Assert.Contains("chown cdidx:cdidx /repo", dockerfile); + Assert.DoesNotContain("USER cdidx:cdidx", dockerfile); + Assert.Contains("stat -c '%u' /repo", entrypoint); + Assert.Contains("stat -c '%g' /repo", entrypoint); + Assert.Contains("su-exec \"${target_uid}:${target_gid}\" cdidx \"$@\"", entrypoint); + Assert.DoesNotContain("COPY . .", dockerfile); + Assert.Contains("COPY Directory.Build.props nuget.config version.json ./", dockerfile); + Assert.Contains("COPY src/CodeIndex/CodeIndex.csproj src/CodeIndex/packages.lock.json src/CodeIndex/", dockerfile); + Assert.Contains("COPY src/CodeIndex/ src/CodeIndex/", dockerfile); + Assert.Contains("ARG CDIDX_BUILD_COMMIT=unknown", dockerfile); + Assert.Contains("-p:CdidxBuildCommitOverride=\"$CDIDX_BUILD_COMMIT\"", dockerfile); + Assert.Contains("-p:CdidxBuildDateOverride=\"$build_date\"", dockerfile); + Assert.Contains("-p:CdidxBuildDirtyOverride=\"$CDIDX_BUILD_DIRTY\"", dockerfile); + Assert.Contains(".git/", dockerignore); + Assert.Contains("tests/", dockerignore); + Assert.Contains("tools/", dockerignore); + Assert.Contains("docs/", dockerignore); + Assert.Contains("changelog.d/", dockerignore); + Assert.Contains("*.md", dockerignore); + Assert.Contains("!COMMERCIAL_LICENSE.md", dockerignore); Assert.Contains("ARG TARGETARCH=amd64", dockerfile); Assert.Contains("linux-musl-x64", dockerfile); Assert.Contains("linux-musl-arm64", dockerfile); - Assert.Contains("ENTRYPOINT [\"cdidx\"]", dockerfile); + Assert.Contains("ENTRYPOINT [\"/usr/local/bin/cdidx-entrypoint\"]", dockerfile); + Assert.Contains("CdidxBuildCommitOverride", project); + Assert.Contains("CdidxBuildDateOverride", project); + Assert.Contains("CdidxBuildDirtyOverride", project); Assert.Contains("Microsoft.NET.ILLink.Tasks\" Version=\"8.", project); Assert.DoesNotContain("Microsoft.NET.ILLink.Tasks\" Version=\"10.", project); }