diff --git a/.github/workflows/ghcr-image-build-and-publish.yml b/.github/workflows/ghcr-image-build-and-publish.yml index 47084653b93..c66a77c7a24 100644 --- a/.github/workflows/ghcr-image-build-and-publish.yml +++ b/.github/workflows/ghcr-image-build-and-publish.yml @@ -31,19 +31,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + # FIXME: setup-qemu-action is depended by `gomodjail pack` - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -53,17 +54,19 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/job-build.yml b/.github/workflows/job-build.yml index 63c3c3e309d..42078e59c95 100644 --- a/.github/workflows/job-build.yml +++ b/.github/workflows/job-build.yml @@ -35,12 +35,14 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - if: ${{ inputs.canary }} name: "Init (canary): retrieve GO_VERSION" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | . ./hack/github/action-helpers.sh latest_go="$(. ./hack/provisioning/version/fetch.sh; go::canary::for::go-setup)" @@ -50,7 +52,7 @@ jobs: - if: ${{ env.GO_VERSION != '' }} name: "Init: install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true @@ -70,6 +72,8 @@ jobs: local goarm="${3:-}" local result + GOOS="$goos" GOARCH="$goarch" GOARM="$goarm" go build ./examples/... + github::timer::begin GOOS="$goos" GOARCH="$goarch" GOARM="$goarm" make binaries \ @@ -88,11 +92,21 @@ jobs: build linux arm64 build windows build freebsd + # These architectures are not released, but we still verify that we can at least compile build darwin build linux arm 6 - # These architectures are not released, but we still verify that we can at least compile + build linux loong64 build linux ppc64le build linux riscv64 build linux s390x [ ! "$failure" ] || exit 1 + + - if: ${{ env.GO_VERSION != '' }} + name: "Run: make binaries with custom BUILDTAGS" + run: | + set -eux + # no_ipfs: make sure it does not incur any IPFS-related dependency + go mod vendor + rm -rf vendor/github.com/ipfs vendor/github.com/multiformats + BUILDTAGS=no_ipfs make binaries diff --git a/.github/workflows/job-lint-go.yml b/.github/workflows/job-lint-go.yml index 8ca82c91506..e6f528e9bdf 100644 --- a/.github/workflows/job-lint-go.yml +++ b/.github/workflows/job-lint-go.yml @@ -39,12 +39,14 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - if: ${{ inputs.canary }} name: "Init (canary): retrieve GO_VERSION" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | latest_go="$(. ./hack/provisioning/version/fetch.sh; go::canary::for::go-setup)" printf "GO_VERSION=%s\n" "$latest_go" >> "$GITHUB_ENV" @@ -53,7 +55,7 @@ jobs: - if: ${{ env.GO_VERSION != '' }} name: "Init: install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true diff --git a/.github/workflows/job-lint-other.yml b/.github/workflows/job-lint-other.yml index 2f012789877..4de2f1457b8 100644 --- a/.github/workflows/job-lint-other.yml +++ b/.github/workflows/job-lint-other.yml @@ -25,7 +25,7 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 diff --git a/.github/workflows/job-lint-project.yml b/.github/workflows/job-lint-project.yml index a3c840642a5..1af262636c3 100644 --- a/.github/workflows/job-lint-project.yml +++ b/.github/workflows/job-lint-project.yml @@ -30,13 +30,13 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 100 path: src/github.com/containerd/nerdctl - name: "Init: install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ inputs.go-version }} check-latest: true @@ -49,8 +49,11 @@ jobs: repo-access-token: ${{ secrets.GITHUB_TOKEN }} # go-licenses-ignore is set because go-licenses cannot detect the license of the following package: # * go-base36: Apache-2.0 OR MIT (https://github.com/multiformats/go-base36/blob/master/LICENSE.md) + # * filepath-securejoin: MPL-2.0 AND BSD-3-Clause, exceptionally approved by CNCF + # (https://github.com/cncf/foundation/issues/1154#issuecomment-3562385979) # # The list of the CNCF-approved licenses can be found here: # https://github.com/cncf/foundation/blob/main/allowed-third-party-license-policy.md go-licenses-ignore: | github.com/multiformats/go-base36 + github.com/cyphar/filepath-securejoin diff --git a/.github/workflows/job-test-dependencies.yml b/.github/workflows/job-test-dependencies.yml index c4457bae1c7..61c55559ae0 100644 --- a/.github/workflows/job-test-dependencies.yml +++ b/.github/workflows/job-test-dependencies.yml @@ -31,7 +31,7 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 @@ -39,6 +39,8 @@ jobs: uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0 - name: "Run: build dependencies for the integration test environment image" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Cache is sharded per-architecture arch=${{ env.RUNNER_ARCH == 'ARM64' && 'arm64' || 'amd64' }} @@ -49,6 +51,7 @@ jobs: args=(--build-arg CONTAINERD_VERSION=${{ inputs.containerd-version }}) fi docker buildx build \ + --secret id=github_token,env=GITHUB_TOKEN \ --cache-to type=gha,compression=zstd,mode=max,scope=test-integration-dependencies-"$arch" \ --cache-from type=gha,scope=test-integration-dependencies-"$arch" \ --target build-dependencies "${args[@]}" . diff --git a/.github/workflows/job-test-in-container.yml b/.github/workflows/job-test-in-container.yml index 6c1b9bae492..43790b93e4d 100644 --- a/.github/workflows/job-test-in-container.yml +++ b/.github/workflows/job-test-in-container.yml @@ -35,6 +35,10 @@ on: required: false default: false type: boolean + skip-flaky: + required: false + default: false + type: boolean env: GOTOOLCHAIN: local @@ -63,13 +67,17 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - name: "Init: expose GitHub Runtime variables for gha" uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0 + - name: "Init: install br-netfilter" + run: | + # This ensures that bridged traffic goes through netfilter + sudo modprobe br-netfilter - name: "Init: register QEMU (tonistiigi/binfmt)" run: | # `--install all` will only install emulation for architectures that cannot be natively executed @@ -81,11 +89,15 @@ jobs: docker run --privileged --rm tonistiigi/binfmt --install linux/arm/v7 - if: ${{ inputs.canary }} name: "Init (canary): prepare updated test image" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | . ./hack/build-integration-canary.sh canary::build::integration - if: ${{ ! inputs.canary }} name: "Init: prepare test image" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | buildargs=() # If the runner is old, use old ubuntu inside the container as well @@ -104,6 +116,7 @@ jobs: arch=${{ env.RUNNER_ARCH == 'ARM64' && 'arm64' || 'amd64' }} docker buildx create --name with-gha --use docker buildx build \ + --secret id=github_token,env=GITHUB_TOKEN \ --output=type=docker \ --cache-from type=gha,scope=test-integration-dependencies-"$arch" \ -t "$target" --target "$target" \ @@ -140,7 +153,17 @@ jobs: sudo sysctl -w net.ipv4.ip_forward=1 # Enable IPv6 for Docker, and configure docker to use containerd for gha sudo mkdir -p /etc/docker - echo '{"ipv6": true, "fixed-cidr-v6": "2001:db8:1::/64", "experimental": true, "ip6tables": true}' | sudo tee /etc/docker/daemon.json + echo '{"ipv6": true, "fixed-cidr-v6": "2001:db8:1::/64", "ip6tables": true}' | sudo tee /etc/docker/daemon.json + - name: "Init: enable Docker experimental features" + run: | + sudo mkdir -p /etc/docker + if [ -f /etc/docker/daemon.json ]; then + tmpfile="$(sudo mktemp)" + sudo jq '.experimental = true' /etc/docker/daemon.json | sudo tee "$tmpfile" >/dev/null + sudo mv "$tmpfile" /etc/docker/daemon.json + else + echo '{"experimental": true}' | sudo tee /etc/docker/daemon.json >/dev/null + fi sudo systemctl restart docker - name: "Run: integration tests" run: | @@ -162,6 +185,7 @@ jobs: fi # FIXME: this NEEDS to go away - name: "Run: integration tests (flaky)" + if: ${{ !fromJSON(inputs.skip-flaky) }} run: | . ./hack/github/action-helpers.sh github::md::h2 "flaky" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/job-test-in-host.yml b/.github/workflows/job-test-in-host.yml index f3d73cdae91..8cba6f34c90 100644 --- a/.github/workflows/job-test-in-host.yml +++ b/.github/workflows/job-test-in-host.yml @@ -22,6 +22,9 @@ on: go-version: required: true type: string + docker-version: + required: true + type: string containerd-version: required: true type: string @@ -68,12 +71,14 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - if: ${{ inputs.canary }} name: "Init (canary): retrieve latest go and containerd" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | latest_go="$(. ./hack/provisioning/version/fetch.sh; go::canary::for::go-setup)" latest_containerd="$(. ./hack/provisioning/version/fetch.sh; github::project::latest "containerd/containerd")" @@ -91,7 +96,7 @@ jobs: - if: ${{ env.SHOULD_RUN == 'yes' }} name: "Init: install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true @@ -102,9 +107,21 @@ jobs: name: "Init (linux): prepare host" run: | if [ "${{ contains(inputs.binary, 'docker') }}" == true ]; then - echo "::group:: configure cdi for docker" + echo "::group:: configure cdi and experimental for docker" sudo mkdir -p /etc/docker - sudo jq '.features.cdi = true' /etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.tmp && sudo mv /etc/docker/daemon.json.tmp /etc/docker/daemon.json + sudo jq -n '.features.cdi = true | .experimental = true' | sudo tee /etc/docker/daemon.json + echo "::endgroup::" + echo "::group:: downgrade docker to the specific version we want to test (${{ inputs.docker-version }})" + sudo apt-get update -qq + sudo apt-get install -qq ca-certificates curl + sudo install -m 0755 -d /etc/apt/keyrings + sudo cp ./hack/provisioning/gpg/docker /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + sudo apt-get update -qq + sudo apt-get install -qq --allow-downgrades docker-ce=${{ inputs.docker-version }} docker-ce-cli=${{ inputs.docker-version }} sudo systemctl restart docker echo "::endgroup::" else @@ -129,12 +146,20 @@ jobs: # Since some arm64 platforms do provide native fallback execution for 32 bits, # armv7 emulation may or may not be installed, causing variance in the result of `uname -m`. # To avoid that, we explicitly list the architectures we do want emulation for. - docker run --privileged --rm tonistiigi/binfmt --install linux/amd64 - docker run --privileged --rm tonistiigi/binfmt --install linux/arm64 - docker run --privileged --rm tonistiigi/binfmt --install linux/arm/v7 + echo "::group:: install binfmt" + docker run --quiet --privileged --rm tonistiigi/binfmt --install linux/amd64 + docker run --quiet --privileged --rm tonistiigi/binfmt --install linux/arm64 + docker run --quiet --privileged --rm tonistiigi/binfmt --install linux/arm/v7 + echo "::endgroup::" # FIXME: remove expect when we are done removing unbuffer from tests - sudo apt-get install -qq expect + echo "::group:: installing test dependencies" + sudo add-apt-repository ppa:criu/ppa -y + sudo apt-get install -qq expect criu + echo "::endgroup::" + + # This ensures that bridged traffic goes through netfilter + sudo modprobe br-netfilter - if: ${{ contains(inputs.runner, 'windows') && env.SHOULD_RUN == 'yes' }} name: "Init (windows): prepare host" diff --git a/.github/workflows/job-test-in-lima.yml b/.github/workflows/job-test-in-lima.yml index 22e2f3e9f8b..ebfddd0dbab 100644 --- a/.github/workflows/job-test-in-lima.yml +++ b/.github/workflows/job-test-in-lima.yml @@ -16,6 +16,10 @@ on: guest: required: true type: string + skip-flaky: + required: false + default: false + type: boolean jobs: test: @@ -26,16 +30,16 @@ jobs: TARGET: ${{ inputs.target }} steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - name: "Init: lima" - uses: lima-vm/lima-actions/setup@be564a1408f84557d067b099a475652288074b2e # v1.0.0 + uses: lima-vm/lima-actions/setup@55627e31b78637bf254a8b2a14da8ea7d12564e5 # v1.1.0 id: lima-actions-setup - name: "Init: Cache" - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.cache/lima key: lima-${{ steps.lima-actions-setup.outputs.version }} @@ -75,10 +79,16 @@ jobs: docker info docker version + - name: "Init: install br-netfilter in the guest VM" + run: | + lima sudo modprobe br-netfilter + - name: "Init: expose GitHub Runtime variables for gha" uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0 - name: "Init: prepare integration tests" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -eux @@ -88,6 +98,7 @@ jobs: [ "$TARGET" = "rootless" ] && TARGET=test-integration-rootless || TARGET=test-integration docker buildx create --name with-gha --use docker buildx build \ + --secret id=github_token,env=GITHUB_TOKEN \ --output=type=docker \ --cache-from type=gha,scope=test-integration-dependencies-amd64 \ -t test-integration --target "${TARGET}" \ @@ -107,6 +118,7 @@ jobs: docker run -t -v /dev:/dev --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=false fi - name: "Run: integration tests (flaky)" + if: ${{ !fromJSON(inputs.skip-flaky) }} run: | set -eux if [ "$TARGET" = "rootless" ]; then diff --git a/.github/workflows/job-test-in-vagrant.yml b/.github/workflows/job-test-in-vagrant.yml index 843606c0987..37ea275605d 100644 --- a/.github/workflows/job-test-in-vagrant.yml +++ b/.github/workflows/job-test-in-vagrant.yml @@ -20,12 +20,12 @@ jobs: runs-on: "${{ inputs.runner }}" steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - name: "Init: setup cache" - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: /root/.vagrant.d key: vagrant @@ -35,7 +35,7 @@ jobs: # from https://github.com/containerd/containerd/blob/v2.0.2/.github/workflows/ci.yml#L583-L596 # which is based on https://github.com/opencontainers/runc/blob/v1.1.8/.cirrus.yml#L41-L49 # FIXME: https://github.com/containerd/nerdctl/issues/4163 - curl -fsSL --proto '=https' --tlsv1.2 https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg + cat ./hack/provisioning/gpg/hashicorp | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update -qq diff --git a/.github/workflows/job-test-unit.yml b/.github/workflows/job-test-unit.yml index c623b402b2b..316916809ef 100644 --- a/.github/workflows/job-test-unit.yml +++ b/.github/workflows/job-test-unit.yml @@ -46,13 +46,15 @@ jobs: steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 # If canary is requested, check for the latest unstable release - if: ${{ inputs.canary }} name: "Init (canary): retrieve GO_VERSION" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | latest_go="$(. ./hack/provisioning/version/fetch.sh; go::canary::for::go-setup)" printf "GO_VERSION=%s\n" "$latest_go" >> "$GITHUB_ENV" @@ -61,19 +63,22 @@ jobs: - if: ${{ env.GO_VERSION != '' }} name: "Init: install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true - # Install CNI + # Install CNI and CRIU - if: ${{ env.GO_VERSION != '' }} - name: "Init: set up CNI" + name: "Init: set up CNI and CRIU" run: | if [ "$RUNNER_OS" == "Windows" ]; then GOPATH=$(go env GOPATH) WINCNI_VERSION=${{ inputs.windows-cni-version }} ./hack/provisioning/windows/cni.sh elif [ "$RUNNER_OS" == "Linux" ]; then ./hack/provisioning/linux/cni.sh install "${{ inputs.linux-cni-version }}" "amd64" "${{ inputs.linux-cni-sha }}" + sudo apt-get update -qq + sudo add-apt-repository ppa:criu/ppa -y + sudo apt-get install -qq criu fi - if: ${{ env.GO_VERSION != '' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a920a20a52..879ae162430 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' - 'test-action-release-*' + pull_request: + paths-ignore: + - '**.md' env: GOTOOLCHAIN: local @@ -20,13 +23,18 @@ jobs: id-token: write # for provenances attestations: write # for provenances steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + # FIXME: setup-qemu-action is depended by `gomodjail pack` + - name: "Set up QEMU" + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: "Install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: - go-version: "1.24" + go-version: "1.25" check-latest: true - name: "Compile binaries" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: make artifacts - name: "SHA256SUMS" run: | @@ -48,11 +56,12 @@ jobs: Release manager: [ADD YOUR NAME HERE] (@[ADD YOUR GITHUB ID HERE]) EOF - name: "Generate artifact attestation" - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') with: subject-path: _output/* - name: "Create release" + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.github/workflows/workflow-flaky.yml b/.github/workflows/workflow-flaky.yml index 85b5c1dd650..f6c6d890090 100644 --- a/.github/workflows/workflow-flaky.yml +++ b/.github/workflows/workflow-flaky.yml @@ -17,18 +17,19 @@ jobs: strategy: fail-fast: false # EL8 is used for testing compatibility with cgroup v1. - # Unfortunately, EL8 is hard to debug for M1 users (as Lima+M1+EL8 is not runnable because of page size), + # Unfortunately, EL8 is hard to debug for ARM Mac users (as Lima+ARM Mac+EL8 is not runnable because of page size), # and it currently shows numerous issues. - # Thus, EL9 is also added as target (for a limited time?) so that we can figure out which issues are EL8 specific, - # and which issues could be reproduced on EL9 as well (which would be easier to debug). + # ARM Mac users may use oraclelinux-8 instead for debugging cgroup v1 issues, although its kernel is different from + # other EL8 variants. matrix: - guest: ["almalinux-8", "almalinux-9"] + guest: ["almalinux-8"] target: ["rootful", "rootless"] with: timeout: 60 runner: ubuntu-24.04 guest: ${{ matrix.guest }} target: ${{ matrix.target }} + skip-flaky: true # skip the most flaky ones for now test-integration-freebsd: name: "FreeBSD" @@ -45,7 +46,7 @@ jobs: ROOTFUL: true steps: - name: "Init: checkout" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - name: "Run" diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index c6d6f6a4e7a..26f9a321f59 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -34,8 +34,8 @@ jobs: goos: linux canary: true with: - timeout: 5 - go-version: "1.24" + timeout: 10 + go-version: "1.25" runner: ubuntu-24.04 # Note: in GitHub yaml world, if `matrix.canary` is undefined, and is passed to `inputs.canary`, the job # will not run. However, if you test it, it will coerce to `false`, hence: @@ -48,7 +48,7 @@ jobs: uses: ./.github/workflows/job-lint-project.yml with: timeout: 5 - go-version: "1.24" + go-version: "1.25" runner: ubuntu-24.04 # Lint for shell and yaml files @@ -68,10 +68,10 @@ jobs: matrix: include: # Build for both old and stable go - - go-version: "1.23" - go-version: "1.24" + - go-version: "1.25" # Additionally build for canary - - go-version: "1.24" + - go-version: "1.25" canary: true with: timeout: 10 diff --git a/.github/workflows/workflow-test.yml b/.github/workflows/workflow-test.yml index c54e9deb570..9ed3d6b4e9b 100644 --- a/.github/workflows/workflow-test.yml +++ b/.github/workflows/workflow-test.yml @@ -30,7 +30,7 @@ jobs: canary: ${{ matrix.canary && true || false }} # Windows routinely go over 5 minutes timeout: 10 - go-version: 1.24 + go-version: 1.25 windows-cni-version: v0.3.1 linux-cni-version: v1.7.1 linux-cni-sha: 1a28a0506bfe5bcdc981caf1a49eeab7e72da8321f1119b7be85f22621013098 @@ -48,7 +48,7 @@ jobs: - runner: ubuntu-24.04-arm # Additionally build for old containerd on amd - runner: ubuntu-24.04 - containerd-version: v1.6.38 + containerd-version: v1.7.30 with: runner: ${{ matrix.runner }} containerd-version: ${{ matrix.containerd-version }} @@ -69,13 +69,15 @@ jobs: # arm64 - runner: ubuntu-24.04-arm target: rootless + skip-flaky: true # port-slirp4netns - runner: ubuntu-24.04 target: rootless-port-slirp4netns + skip-flaky: true # old containerd + old ubuntu + old rootlesskit - runner: ubuntu-22.04 target: rootless - containerd-version: v1.6.38 + containerd-version: v1.7.30 rootlesskit-version: v1.1.1 # gomodjail - runner: ubuntu-24.04 @@ -88,21 +90,23 @@ jobs: # arm64 - runner: ubuntu-24.04-arm target: rootful + skip-flaky: true # old containerd + old ubuntu - runner: ubuntu-22.04 target: rootful - containerd-version: v1.6.38 + containerd-version: v1.7.30 # ipv6 - runner: ubuntu-24.04 target: rootful ipv6: true + skip-flaky: true # all canary - runner: ubuntu-24.04 target: rootful canary: true with: - timeout: 45 + timeout: 80 runner: ${{ matrix.runner }} target: ${{ matrix.target }} binary: ${{ matrix.binary && matrix.binary || 'nerdctl' }} @@ -110,6 +114,7 @@ jobs: rootlesskit-version: ${{ matrix.rootlesskit-version }} ipv6: ${{ matrix.ipv6 && true || false }} canary: ${{ matrix.canary && true || false }} + skip-flaky: ${{ matrix.skip-flaky && true || false }} test-integration-host: name: "in-host${{ inputs.hack }}" @@ -138,11 +143,12 @@ jobs: runner: ${{ matrix.runner }} binary: ${{ matrix.binary != '' && matrix.binary || 'nerdctl' }} canary: ${{ matrix.canary && true || false }} - go-version: 1.24 + go-version: 1.25 windows-cni-version: v0.3.1 - containerd-version: 2.1.0 + docker-version: 5:28.0.4-1~ubuntu.24.04~noble + containerd-version: 2.2.1 # Note: these as for amd64 - containerd-sha: 0e5359e957b66b679be807563a543c7416e305e3aafcf56bad90ef87a917014d + containerd-sha: f5d8e90ecb6c1c7e33ecddf8cc268a93b9e5b54e0e850320d765511d76624f41 containerd-service-sha: 1941362cbaa89dd591b99c32b050d82c583d3cd2e5fa63085d7017457ec5fca8 - linux-cni-version: v1.7.1 - linux-cni-sha: 1a28a0506bfe5bcdc981caf1a49eeab7e72da8321f1119b7be85f22621013098 + linux-cni-version: v1.9.0 + linux-cni-sha: 58c03705426e929658f45a851df15a86d06ef680cacbf3f2dc127731ca265c28 diff --git a/.github/workflows/workflow-tigron.yml b/.github/workflows/workflow-tigron.yml index 306ef75d55b..91511735664 100644 --- a/.github/workflows/workflow-tigron.yml +++ b/.github/workflows/workflow-tigron.yml @@ -9,7 +9,7 @@ on: paths: 'mod/tigron/**' env: - GO_VERSION: "1.24" + GO_VERSION: "1.25" GOTOOLCHAIN: local jobs: @@ -32,11 +32,13 @@ jobs: canary: go-canary steps: - name: "Checkout project" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 100 - if: ${{ matrix.canary }} name: "Init (canary): retrieve GO_VERSION" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | latest_go="$(. ./hack/provisioning/version/fetch.sh; go::canary::for::go-setup)" printf "GO_VERSION=%s\n" "$latest_go" >> "$GITHUB_ENV" @@ -44,7 +46,7 @@ jobs: echo "::warning title=No canary go::There is currently no canary go version to test. Steps will not run." - if: ${{ env.GO_VERSION != '' }} name: "Install go" - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true @@ -58,16 +60,20 @@ jobs: brew install yamllint shellcheck fi echo "::endgroup::" - - if: ${{ env.GO_VERSION != '' && env.RUNNER_OS == 'Linux' && matrix.goos == '' }} + - if: ${{ env.GO_VERSION != '' && matrix.goos == '' }} name: "lint" env: NO_COLOR: true run: | - echo "::group:: lint" - cd mod/tigron - export LINT_COMMIT_RANGE="$(jq -r '.after + "..HEAD"' ${GITHUB_EVENT_PATH})" - make lint - echo "::endgroup::" + if [ "$RUNNER_OS" == Linux ]; then + echo "::group:: lint" + cd mod/tigron + export LINT_COMMIT_RANGE="$(jq -r '.after + "..HEAD"' ${GITHUB_EVENT_PATH})" + make lint + echo "::endgroup::" + else + echo "Lint is disabled on $RUNNER_OS" + fi - if: ${{ env.GO_VERSION != '' }} name: "test-unit" run: | diff --git a/.golangci.yml b/.golangci.yml index 7fda716e51a..ec60c924491 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -31,6 +31,7 @@ linters: - revive # Gocritic - gocritic + - forbidigo # 3. We used to use these, but have now removed them @@ -41,6 +42,15 @@ linters: # - nakedret settings: + forbidigo: + forbid: + # FIXME: there are still calls to os.WriteFile in tests under `cmd` + - pattern: ^os\.WriteFile.*$ + pkg: github.com/containerd/nerdctl/v2/pkg + msg: os.WriteFile is neither atomic nor durable - use nerdctl filesystem.WriteFile instead + - pattern: ^os\.ReadFile.*$ + pkg: github.com/containerd/nerdctl/v2/pkg + msg: use filesystem.ReadFile instead of os.ReadFile staticcheck: checks: # Below is the default set @@ -53,9 +63,6 @@ linters: - "-ST1022" ##### TODO: fix and enable these - # 4 occurrences. - # Use fmt.Fprintf(x, ...) instead of x.Write(fmt.Sprintf(...)) https://staticcheck.dev/docs/checks#QF1012 - - "-QF1012" # 6 occurrences. # Apply De Morgan’s law https://staticcheck.dev/docs/checks#QF1001 - "-QF1001" @@ -92,6 +99,9 @@ linters: - name: use-errors-new # 84 occurrences. Improves error testing. disabled: true + - name: struct-tag + # 2 occurrences. + disabled: true ##### P1: consider making a dent on these, but not critical. - name: argument-limit @@ -114,7 +124,7 @@ linters: arguments: [7] - name: function-length # 155 occurrences (at default 0, 75). Really long functions should really be broken up in most cases. - arguments: [0, 450] + arguments: [0, 500] - name: cyclomatic # 204 occurrences (at default 10) arguments: [100] @@ -122,8 +132,11 @@ linters: # 222 occurrences. Could indicate failure to handle broken conditions. disabled: true - name: cognitive-complexity - arguments: [197] + arguments: [205] # 441 occurrences (at default 7). We should try to lower it (involves significant refactoring). + - name: var-naming + # 1 occurrence. + disabled: true ##### P2: nice to have. - name: max-public-structs @@ -148,6 +161,9 @@ linters: - name: exported # 577 occurrences. Forces documentation of any exported symbol. disabled: true + - name: unnecessary-format + # Many occurrences. + disabled: true ###### Permanently disabled. Below have been reviewed and vetted to be unnecessary. - name: line-length-limit @@ -168,6 +184,9 @@ linters: - name: add-constant # 2605 occurrences. Kind of useful in itself, but unacceptable amount of effort to fix disabled: true + - name: enforce-switch-style + # Many occurrences. + disabled: true depguard: rules: @@ -234,7 +253,6 @@ linters: - typeAssertChain - unlabelStmt - builtinShadow - - importShadow - initClause - nestingReduce - unnecessaryBlock diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 00000000000..775cc2977aa --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,22 @@ +# Building nerdctl + +To build nerdctl, use `make`: + +```bash +make +sudo make install +``` + +Alternatively, nerdctl can be also built with `go build ./cmd/nerdctl`. +However, this is not recommended as it does not populate the version string (`nerdctl -v`). + +## Customization + +To specify build tags, set the `BUILDTAGS` variable as follows: + +```bash +BUILDTAGS=no_ipfs make +``` + +The following build tags are supported: +* `no_ipfs` (since v2.1.3): Disable IPFS diff --git a/Dockerfile b/Dockerfile index 533f16eba18..5ab9d77f08e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,49 +17,52 @@ # Basic deps # @BINARY: the binary checksums are verified via Dockerfile.d/SHA256SUMS.d/- -ARG CONTAINERD_VERSION=v2.1.0@061792f0ecf3684fb30a3a0eb006799b8c6638a7 -ARG RUNC_VERSION=v1.3.0@4ca628d1d4c974f92d24daccb901aa078aad748e -ARG CNI_PLUGINS_VERSION=v1.7.1@BINARY +ARG CONTAINERD_VERSION=v2.2.1@dea7da592f5d1d2b7755e3a161be07f43fad8f75 +ARG RUNC_VERSION=v1.4.0@8bd78a9977e604c4d5f67a7415d7b8b8c109cdc4 +ARG CNI_PLUGINS_VERSION=v1.9.0@BINARY # Extra deps: Build -ARG BUILDKIT_VERSION=v0.21.1@BINARY +ARG BUILDKIT_VERSION=v0.26.3@BINARY # Extra deps: Lazy-pulling -ARG STARGZ_SNAPSHOTTER_VERSION=v0.16.3@BINARY +ARG STARGZ_SNAPSHOTTER_VERSION=v0.18.1@BINARY # Extra deps: Encryption -ARG IMGCRYPT_VERSION=v2.0.1@c377ec98ff79ec9205eabf555ebd2ea784738c6c +ARG IMGCRYPT_VERSION=v2.0.2@6892f4df2405cd15acbefd1dca970f53ba38bfda # Extra deps: Rootless -ARG ROOTLESSKIT_VERSION=v2.3.5@BINARY -ARG SLIRP4NETNS_VERSION=v1.3.2@BINARY +ARG ROOTLESSKIT_VERSION=v2.3.6@BINARY +ARG SLIRP4NETNS_VERSION=v1.3.3@BINARY # Extra deps: bypass4netns ARG BYPASS4NETNS_VERSION=v0.4.2@aa04bd3dcc48c6dae6d7327ba219bda8fe2a4634 # Extra deps: FUSE-OverlayFS -ARG FUSE_OVERLAYFS_VERSION=v1.15@BINARY -ARG CONTAINERD_FUSE_OVERLAYFS_VERSION=v2.1.5@BINARY +ARG FUSE_OVERLAYFS_VERSION=v1.16@BINARY +ARG CONTAINERD_FUSE_OVERLAYFS_VERSION=v2.1.7@BINARY # Extra deps: Init ARG TINI_VERSION=v0.19.0@BINARY # Extra deps: Debug -ARG BUILDG_VERSION=v0.5.2@BINARY +ARG BUILDG_VERSION=v0.5.3@BINARY # Extra deps: gomodjail -ARG GOMODJAIL_VERSION=v0.1.2@0a86b34442a491fa8f5e4565e9c846fce310239c +ARG GOMODJAIL_VERSION=v0.1.3@cea529ddd971b677c67d8af7e936fbc62b35b98c # Test deps # Currently, the Docker Official Images and the test deps are not pinned by the hash -ARG GO_VERSION=1.24 +ARG GO_VERSION=1.25 ARG UBUNTU_VERSION=24.04 ARG CONTAINERIZED_SYSTEMD_VERSION=v0.1.1 -ARG GOTESTSUM_VERSION=v1.12.2 -ARG NYDUS_VERSION=v2.3.1 -ARG SOCI_SNAPSHOTTER_VERSION=0.9.0 -ARG KUBO_VERSION=v0.34.1 +ARG GOTESTSUM_VERSION=v1.13.0 +ARG NYDUS_VERSION=v2.3.9 +ARG SOCI_SNAPSHOTTER_VERSION=0.12.1 +ARG KUBO_VERSION=v0.39.0 -FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.6.1@sha256:923441d7c25f1e2eb5789f82d987693c47b8ed987c4ab3b075d6ed2b5d6779a3 AS xx +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bookworm AS build-base-debian +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-trixie AS build-base COPY --from=xx / / ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update -qq && apt-get install -qq --no-install-recommends \ + make \ git \ + jq \ + curl \ dpkg-dev ARG TARGETARCH # libbtrfs: for containerd @@ -73,11 +76,12 @@ RUN xx-apt-get update -qq && xx-apt-get install -qq --no-install-recommends \ pkg-config RUN git config --global advice.detachedHead false ADD hack/git-checkout-tag-with-hash.sh /usr/local/bin/ +ADD hack/scripts/lib.sh /usr/local/bin/http::helper -FROM build-base-debian AS build-containerd +FROM build-base AS build-containerd ARG TARGETARCH ARG CONTAINERD_VERSION -RUN git clone --quiet --depth 1 --branch "${CONTAINERD_VERSION%@*}" https://github.com/containerd/containerd.git /go/src/github.com/containerd/containerd +RUN git clone --quiet --depth 1 --branch "${CONTAINERD_VERSION%%@*}" https://github.com/containerd/containerd.git /go/src/github.com/containerd/containerd WORKDIR /go/src/github.com/containerd/containerd RUN git-checkout-tag-with-hash.sh ${CONTAINERD_VERSION} && \ mkdir -p /out /out/$TARGETARCH && \ @@ -85,10 +89,10 @@ RUN git-checkout-tag-with-hash.sh ${CONTAINERD_VERSION} && \ RUN GO=xx-go make STATIC=1 && \ cp -a bin/containerd bin/containerd-shim-runc-v2 bin/ctr /out/$TARGETARCH -FROM build-base-debian AS build-runc +FROM build-base AS build-runc ARG RUNC_VERSION ARG TARGETARCH -RUN git clone --quiet --depth 1 --branch "${RUNC_VERSION%@*}" https://github.com/opencontainers/runc.git /go/src/github.com/opencontainers/runc +RUN git clone --quiet --depth 1 --branch "${RUNC_VERSION%%@*}" https://github.com/opencontainers/runc.git /go/src/github.com/opencontainers/runc WORKDIR /go/src/github.com/opencontainers/runc RUN git-checkout-tag-with-hash.sh ${RUNC_VERSION} && \ mkdir -p /out @@ -96,10 +100,10 @@ ENV CGO_ENABLED=1 RUN GO=xx-go CC=$(xx-info)-gcc STRIP=$(xx-info)-strip make static && \ xx-verify --static runc && cp -v -a runc /out/runc.${TARGETARCH} -FROM build-base-debian AS build-bypass4netns +FROM build-base AS build-bypass4netns ARG BYPASS4NETNS_VERSION ARG TARGETARCH -RUN git clone --quiet --depth 1 --branch "${BYPASS4NETNS_VERSION%@*}" https://github.com/rootless-containers/bypass4netns.git /go/src/github.com/rootless-containers/bypass4netns +RUN git clone --quiet --depth 1 --branch "${BYPASS4NETNS_VERSION%%@*}" https://github.com/rootless-containers/bypass4netns.git /go/src/github.com/rootless-containers/bypass4netns WORKDIR /go/src/github.com/rootless-containers/bypass4netns RUN git-checkout-tag-with-hash.sh ${BYPASS4NETNS_VERSION} && \ mkdir -p /out/${TARGETARCH} @@ -107,10 +111,20 @@ ENV CGO_ENABLED=1 RUN GO=xx-go make static && \ xx-verify --static bypass4netns && cp -a bypass4netns bypass4netnsd /out/${TARGETARCH} -FROM build-base-debian AS build-kubo +FROM build-base AS build-gomodjail +ARG GOMODJAIL_VERSION +ARG TARGETARCH +RUN git clone --quiet --depth 1 --branch "${GOMODJAIL_VERSION%%@*}" https://github.com/AkihiroSuda/gomodjail.git /go/src/github.com/AkihiroSuda/gomodjail +WORKDIR /go/src/github.com/AkihiroSuda/gomodjail +RUN git-checkout-tag-with-hash.sh ${GOMODJAIL_VERSION} && \ + mkdir -p /out/${TARGETARCH} +RUN GO=xx-go make STATIC=1 && \ + xx-verify --static _output/bin/gomodjail && cp -a _output/bin/gomodjail /out/${TARGETARCH} + +FROM build-base AS build-kubo ARG KUBO_VERSION ARG TARGETARCH -RUN git clone --quiet --depth 1 --branch "${KUBO_VERSION%@*}" https://github.com/ipfs/kubo.git /go/src/github.com/ipfs/kubo +RUN git clone --quiet --depth 1 --branch "${KUBO_VERSION%%@*}" https://github.com/ipfs/kubo.git /go/src/github.com/ipfs/kubo WORKDIR /go/src/github.com/ipfs/kubo RUN git-checkout-tag-with-hash.sh ${KUBO_VERSION} && \ mkdir -p /out/${TARGETARCH} @@ -119,11 +133,6 @@ RUN xx-go --wrap && \ make build && \ xx-verify --static cmd/ipfs/ipfs && cp -a cmd/ipfs/ipfs /out/${TARGETARCH} -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build-base -RUN apk add --no-cache make git curl -RUN git config --global advice.detachedHead false -ADD hack/git-checkout-tag-with-hash.sh /usr/local/bin/ - FROM build-base AS build-minimal RUN BINDIR=/out/bin make binaries install # We do not set CMD to `go test` here, because it requires systemd @@ -138,27 +147,27 @@ RUN mkdir -p /out/share/doc/nerdctl-full && touch /out/share/doc/nerdctl-full/RE ARG CONTAINERD_VERSION COPY --from=build-containerd /out/${TARGETARCH:-amd64}/* /out/bin/ COPY --from=build-containerd /out/containerd.service /out/lib/systemd/system/containerd.service -RUN echo "- containerd: ${CONTAINERD_VERSION/@*}" >> /out/share/doc/nerdctl-full/README.md +RUN echo "- containerd: ${CONTAINERD_VERSION%%@*}" >> /out/share/doc/nerdctl-full/README.md ARG RUNC_VERSION COPY --from=build-runc /out/runc.${TARGETARCH:-amd64} /out/bin/runc -RUN echo "- runc: ${RUNC_VERSION/@*}" >> /out/share/doc/nerdctl-full/README.md +RUN echo "- runc: ${RUNC_VERSION%%@*}" >> /out/share/doc/nerdctl-full/README.md ARG CNI_PLUGINS_VERSION -RUN CNI_PLUGINS_VERSION=${CNI_PLUGINS_VERSION/@BINARY}; \ +RUN CNI_PLUGINS_VERSION=${CNI_PLUGINS_VERSION%%@*}; \ fname="cni-plugins-${TARGETOS:-linux}-${TARGETARCH:-amd64}-${CNI_PLUGINS_VERSION}.tgz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/cni-plugins-${CNI_PLUGINS_VERSION}" | sha256sum -c && \ mkdir -p /out/libexec/cni && \ tar xzf "${fname}" -C /out/libexec/cni && \ rm -f "${fname}" && \ echo "- CNI plugins: ${CNI_PLUGINS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG BUILDKIT_VERSION -RUN BUILDKIT_VERSION=${BUILDKIT_VERSION/@BINARY}; \ +RUN BUILDKIT_VERSION=${BUILDKIT_VERSION%%@*}; \ fname="buildkit-${BUILDKIT_VERSION}.${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/buildkit-${BUILDKIT_VERSION}" | sha256sum -c && \ tar xzf "${fname}" -C /out && \ rm -f "${fname}" /out/bin/buildkit-qemu-* /out/bin/buildkit-cni-* /out/bin/buildkit-runc && \ - for f in /out/libexec/cni/*; do ln -s ../libexec/cni/$(basename $f) /out/bin/buildkit-cni-$(basename $f); done && \ + for f in /out/libexec/cni/*; do [ -x "$f" ] && [ -f "$f" ] && ln -s ../libexec/cni/$(basename $f) /out/bin/buildkit-cni-$(basename $f); done && \ echo "- BuildKit: ${BUILDKIT_VERSION}" >> /out/share/doc/nerdctl-full/README.md # NOTE: github.com/moby/buildkit/examples/systemd is not included in BuildKit v0.8.x, will be included in v0.9.x RUN cd /out/lib/systemd/system && \ @@ -167,10 +176,11 @@ RUN cd /out/lib/systemd/system && \ echo "" >> buildkit.service && \ echo "# This file was converted from containerd.service, with \`sed -E '${sedcomm}'\`" >> buildkit.service ARG STARGZ_SNAPSHOTTER_VERSION -RUN STARGZ_SNAPSHOTTER_VERSION=${STARGZ_SNAPSHOTTER_VERSION/@BINARY}; \ +RUN --mount=type=secret,id=github_token,env=GITHUB_TOKEN \ + STARGZ_SNAPSHOTTER_VERSION=${STARGZ_SNAPSHOTTER_VERSION%%@*}; \ fname="stargz-snapshotter-${STARGZ_SNAPSHOTTER_VERSION}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/containerd/stargz-snapshotter/releases/download/${STARGZ_SNAPSHOTTER_VERSION}/${fname}" && \ - curl -o "stargz-snapshotter.service" -fsSL --proto '=https' --tlsv1.2 "https://raw.githubusercontent.com/containerd/stargz-snapshotter/${STARGZ_SNAPSHOTTER_VERSION}/script/config/etc/systemd/system/stargz-snapshotter.service" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/containerd/stargz-snapshotter/releases/download/${STARGZ_SNAPSHOTTER_VERSION}/${fname}" && \ + http::helper github::file containerd/stargz-snapshotter script/config/etc/systemd/system/stargz-snapshotter.service "${STARGZ_SNAPSHOTTER_VERSION}" > "stargz-snapshotter.service" && \ grep "${fname}" "/SHA256SUMS.d/stargz-snapshotter-${STARGZ_SNAPSHOTTER_VERSION}" | sha256sum -c - && \ grep "stargz-snapshotter.service" "/SHA256SUMS.d/stargz-snapshotter-${STARGZ_SNAPSHOTTER_VERSION}" | sha256sum -c - && \ tar xzf "${fname}" -C /out/bin && \ @@ -178,75 +188,77 @@ RUN STARGZ_SNAPSHOTTER_VERSION=${STARGZ_SNAPSHOTTER_VERSION/@BINARY}; \ mv stargz-snapshotter.service /out/lib/systemd/system/stargz-snapshotter.service && \ echo "- Stargz Snapshotter: ${STARGZ_SNAPSHOTTER_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG IMGCRYPT_VERSION -RUN git clone --quiet --depth 1 --branch "${IMGCRYPT_VERSION%@*}" https://github.com/containerd/imgcrypt.git /go/src/github.com/containerd/imgcrypt && \ +RUN git clone --quiet --depth 1 --branch "${IMGCRYPT_VERSION%%@*}" https://github.com/containerd/imgcrypt.git /go/src/github.com/containerd/imgcrypt && \ cd /go/src/github.com/containerd/imgcrypt && \ git-checkout-tag-with-hash.sh "${IMGCRYPT_VERSION}" && \ CGO_ENABLED=0 make && DESTDIR=/out make install && \ - echo "- imgcrypt: ${IMGCRYPT_VERSION/@*}" >> /out/share/doc/nerdctl-full/README.md + echo "- imgcrypt: ${IMGCRYPT_VERSION%%@*}" >> /out/share/doc/nerdctl-full/README.md ARG SLIRP4NETNS_VERSION -RUN SLIRP4NETNS_VERSION=${SLIRP4NETNS_VERSION/@BINARY}; \ +RUN SLIRP4NETNS_VERSION=${SLIRP4NETNS_VERSION%%@*}; \ fname="slirp4netns-$(cat /target_uname_m)" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/rootless-containers/slirp4netns/releases/download/${SLIRP4NETNS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/rootless-containers/slirp4netns/releases/download/${SLIRP4NETNS_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/slirp4netns-${SLIRP4NETNS_VERSION}" | sha256sum -c && \ mv "${fname}" /out/bin/slirp4netns && \ chmod +x /out/bin/slirp4netns && \ echo "- slirp4netns: ${SLIRP4NETNS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG BYPASS4NETNS_VERSION COPY --from=build-bypass4netns /out/${TARGETARCH:-amd64}/* /out/bin/ -RUN echo "- bypass4netns: ${BYPASS4NETNS_VERSION/@*}" >> /out/share/doc/nerdctl-full/README.md +RUN echo "- bypass4netns: ${BYPASS4NETNS_VERSION%%@*}" >> /out/share/doc/nerdctl-full/README.md ARG FUSE_OVERLAYFS_VERSION -RUN FUSE_OVERLAYFS_VERSION=${FUSE_OVERLAYFS_VERSION/@BINARY}; \ +RUN FUSE_OVERLAYFS_VERSION=${FUSE_OVERLAYFS_VERSION%%@*}; \ fname="fuse-overlayfs-$(cat /target_uname_m)" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/containers/fuse-overlayfs/releases/download/${FUSE_OVERLAYFS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/containers/fuse-overlayfs/releases/download/${FUSE_OVERLAYFS_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/fuse-overlayfs-${FUSE_OVERLAYFS_VERSION}" | sha256sum -c && \ mv "${fname}" /out/bin/fuse-overlayfs && \ chmod +x /out/bin/fuse-overlayfs && \ echo "- fuse-overlayfs: ${FUSE_OVERLAYFS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG CONTAINERD_FUSE_OVERLAYFS_VERSION -RUN CONTAINERD_FUSE_OVERLAYFS_VERSION=${CONTAINERD_FUSE_OVERLAYFS_VERSION/@BINARY}; \ - fname="containerd-fuse-overlayfs-${CONTAINERD_FUSE_OVERLAYFS_VERSION/v}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/containerd/fuse-overlayfs-snapshotter/releases/download/${CONTAINERD_FUSE_OVERLAYFS_VERSION}/${fname}" && \ +RUN CONTAINERD_FUSE_OVERLAYFS_VERSION=${CONTAINERD_FUSE_OVERLAYFS_VERSION%%@*}; \ + fname="containerd-fuse-overlayfs-${CONTAINERD_FUSE_OVERLAYFS_VERSION##*v}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/containerd/fuse-overlayfs-snapshotter/releases/download/${CONTAINERD_FUSE_OVERLAYFS_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/containerd-fuse-overlayfs-${CONTAINERD_FUSE_OVERLAYFS_VERSION}" | sha256sum -c && \ tar xzf "${fname}" -C /out/bin && \ rm -f "${fname}" && \ echo "- containerd-fuse-overlayfs: ${CONTAINERD_FUSE_OVERLAYFS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG TINI_VERSION -RUN TINI_VERSION=${TINI_VERSION/@BINARY}; \ +RUN TINI_VERSION=${TINI_VERSION%%@*}; \ fname="tini-static-${TARGETARCH:-amd64}" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/tini-${TINI_VERSION}" | sha256sum -c && \ cp -a "${fname}" /out/bin/tini && chmod +x /out/bin/tini && \ echo "- Tini: ${TINI_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG BUILDG_VERSION -RUN BUILDG_VERSION=${BUILDG_VERSION/@BINARY}; \ +# FIXME: this is a mildly-confusing approach. Buildkit will perform some "smart" replacement at build time and output +# confusing debugging information, eg: BUILDG_VERSION will appear as if the original ARG value was used. +RUN BUILDG_VERSION=${BUILDG_VERSION%%@*}; \ fname="buildg-${BUILDG_VERSION}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/ktock/buildg/releases/download/${BUILDG_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/ktock/buildg/releases/download/${BUILDG_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/buildg-${BUILDG_VERSION}" | sha256sum -c && \ tar xzf "${fname}" -C /out/bin && \ rm -f "${fname}" && \ echo "- buildg: ${BUILDG_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG ROOTLESSKIT_VERSION -RUN ROOTLESSKIT_VERSION=${ROOTLESSKIT_VERSION/@BINARY}; \ +RUN ROOTLESSKIT_VERSION=${ROOTLESSKIT_VERSION%%@*}; \ fname="rootlesskit-$(cat /target_uname_m).tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/rootless-containers/rootlesskit/releases/download/${ROOTLESSKIT_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/rootless-containers/rootlesskit/releases/download/${ROOTLESSKIT_VERSION}/${fname}" && \ grep "${fname}" "/SHA256SUMS.d/rootlesskit-${ROOTLESSKIT_VERSION}" | sha256sum -c && \ tar xzf "${fname}" -C /out/bin && \ rm -f "${fname}" /out/bin/rootlesskit-docker-proxy && \ echo "- RootlessKit: ${ROOTLESSKIT_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG GOMODJAIL_VERSION -RUN git clone https://github.com/AkihiroSuda/gomodjail.git /go/src/github.com/AkihiroSuda/gomodjail && \ - cd /go/src/github.com/AkihiroSuda/gomodjail && \ - git-checkout-tag-with-hash.sh "${GOMODJAIL_VERSION}" && \ - make STATIC=1 && \ - cp -a _output/bin/gomodjail /out/bin/ && \ - echo "- gomodjail: ${GOMODJAIL_VERSION}" >> /out/share/doc/nerdctl-full/README.md +COPY --from=build-gomodjail /out/${TARGETARCH:-amd64}/* /out/bin/ +RUN echo "- gomodjail: ${GOMODJAIL_VERSION}" >> /out/share/doc/nerdctl-full/README.md +ARG CONTAINERIZED_SYSTEMD_VERSION +RUN --mount=type=secret,id=github_token,env=GITHUB_TOKEN \ + http::helper github::file AkihiroSuda/containerized-systemd docker-entrypoint.sh "${CONTAINERIZED_SYSTEMD_VERSION}" > /docker-entrypoint.sh && \ + chmod +x /docker-entrypoint.sh RUN echo "" >> /out/share/doc/nerdctl-full/README.md && \ echo "## License" >> /out/share/doc/nerdctl-full/README.md && \ - echo "- bin/slirp4netns: [GNU GENERAL PUBLIC LICENSE, Version 2](https://github.com/rootless-containers/slirp4netns/blob/${SLIRP4NETNS_VERSION/@*}/COPYING)" >> /out/share/doc/nerdctl-full/README.md && \ - echo "- bin/fuse-overlayfs: [GNU GENERAL PUBLIC LICENSE, Version 2](https://github.com/containers/fuse-overlayfs/blob/${FUSE_OVERLAYFS_VERSION/@*}/COPYING)" >> /out/share/doc/nerdctl-full/README.md && \ + echo "- bin/slirp4netns: [GNU GENERAL PUBLIC LICENSE, Version 2](https://github.com/rootless-containers/slirp4netns/blob/${SLIRP4NETNS_VERSION%%@*}/COPYING)" >> /out/share/doc/nerdctl-full/README.md && \ + echo "- bin/fuse-overlayfs: [GNU GENERAL PUBLIC LICENSE, Version 2](https://github.com/containers/fuse-overlayfs/blob/${FUSE_OVERLAYFS_VERSION%%@*}/COPYING)" >> /out/share/doc/nerdctl-full/README.md && \ echo "- bin/{runc,bypass4netns,bypass4netnsd}: Apache License 2.0, statically linked with libseccomp ([LGPL 2.1](https://github.com/seccomp/libseccomp/blob/main/LICENSE), source code available at https://github.com/seccomp/libseccomp/)" >> /out/share/doc/nerdctl-full/README.md && \ - echo "- bin/tini: [MIT License](https://github.com/krallin/tini/blob/${TINI_VERSION/@*}/LICENSE)" >> /out/share/doc/nerdctl-full/README.md && \ + echo "- bin/tini: [MIT License](https://github.com/krallin/tini/blob/${TINI_VERSION%%@*}/LICENSE)" >> /out/share/doc/nerdctl-full/README.md && \ echo "- Other files: [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)" >> /out/share/doc/nerdctl-full/README.md FROM build-dependencies AS build-full @@ -254,6 +266,8 @@ COPY . /go/src/github.com/containerd/nerdctl RUN { echo "# nerdctl (full distribution)"; echo "- nerdctl: $(cd /go/src/github.com/containerd/nerdctl && git describe --tags)"; cat /out/share/doc/nerdctl-full/README.md; } > /out/share/doc/nerdctl-full/README.md.new; mv /out/share/doc/nerdctl-full/README.md.new /out/share/doc/nerdctl-full/README.md WORKDIR /go/src/github.com/containerd/nerdctl RUN BINDIR=/out/bin make binaries install +# FIXME: `gomodjail pack` depends on QEMU for non-native architecture +# TODO: gomodjail should provide a plain shell script that utilizes `zip(1)` for packing the self-extract archive, without running `gomodjail pack`.. RUN /out/bin/gomodjail pack --go-mod=/go/src/github.com/containerd/nerdctl/go.mod /out/bin/nerdctl && \ cp -a nerdctl.gomodjail /out/bin/ COPY README.md /out/share/doc/nerdctl/ @@ -274,9 +288,7 @@ RUN apt-get update -qq && apt-get install -qq -y --no-install-recommends \ iproute2 iptables \ dbus dbus-user-session systemd systemd-sysv \ fuse3 -ARG CONTAINERIZED_SYSTEMD_VERSION -RUN curl -o /docker-entrypoint.sh -fsSL --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/AkihiroSuda/containerized-systemd/${CONTAINERIZED_SYSTEMD_VERSION}/docker-entrypoint.sh && \ - chmod +x /docker-entrypoint.sh +COPY --from=build-full /docker-entrypoint.sh /docker-entrypoint.sh COPY --from=out-full / /usr/local/ RUN perl -pi -e 's/multi-user.target/docker-entrypoint.target/g' /usr/local/lib/systemd/system/*.service && \ systemctl enable containerd buildkit stargz-snapshotter && \ @@ -297,12 +309,19 @@ ARG DEBIAN_FRONTEND=noninteractive # `expect` package contains `unbuffer(1)`, which is used for emulating TTY for testing # `jq` is required to generate test summaries RUN apt-get update -qq && apt-get install -qq --no-install-recommends \ - expect \ - jq \ - git \ - make + software-properties-common \ + gnupg \ + gpg-agent \ + ca-certificates && \ + add-apt-repository ppa:criu/ppa && \ + apt-get update -qq && apt-get install -qq --no-install-recommends \ + expect \ + jq \ + git \ + make \ + criu # We wouldn't need this if Docker Hub could have "golang:${GO_VERSION}-ubuntu" -COPY --from=build-base-debian /usr/local/go /usr/local/go +COPY --from=build-base /usr/local/go /usr/local/go ARG TARGETARCH ENV PATH=/usr/local/go/bin:$PATH ARG GOTESTSUM_VERSION @@ -316,8 +335,11 @@ COPY --from=ghcr.io/sigstore/cosign/cosign:v2.2.3@sha256:8fc9cad121611e8479f65f7 # installing soci for integration test ARG SOCI_SNAPSHOTTER_VERSION RUN fname="soci-snapshotter-${SOCI_SNAPSHOTTER_VERSION}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fsSL --proto '=https' --tlsv1.2 "https://github.com/awslabs/soci-snapshotter/releases/download/v${SOCI_SNAPSHOTTER_VERSION}/${fname}" && \ - tar -C /usr/local/bin -xvf "${fname}" soci soci-snapshotter-grpc + curl -o "${fname}" -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/awslabs/soci-snapshotter/releases/download/v${SOCI_SNAPSHOTTER_VERSION}/${fname}" && \ + tar -C /usr/local/bin -xvf "${fname}" soci soci-snapshotter-grpc && \ + mkdir -p /etc/soci-snapshotter-grpc && \ + touch /etc/soci-snapshotter-grpc/config.toml && \ + echo "\n[pull_modes]\n [pull_modes.soci_v1]\n enable = true" >> /etc/soci-snapshotter-grpc/config.toml # enable offline ipfs for integration test COPY --from=build-kubo /out/${TARGETARCH:-amd64}/* /usr/local/bin/ COPY ./Dockerfile.d/test-integration-etc_containerd-stargz-grpc_config.toml /etc/containerd-stargz-grpc/config.toml @@ -334,7 +356,7 @@ RUN systemctl enable test-integration-ipfs-offline test-integration-buildkit-ner ipfs config Addresses.Gateway "/ip4/127.0.0.1/tcp/5889" # install nydus components ARG NYDUS_VERSION -RUN curl -o nydus-static.tgz -fsSL --proto '=https' --tlsv1.2 "https://github.com/dragonflyoss/image-service/releases/download/${NYDUS_VERSION}/nydus-static-${NYDUS_VERSION}-linux-${TARGETARCH}.tgz" && \ +RUN curl -o nydus-static.tgz -fsSL --retry 5 --retry-delay 5 --retry-max-time 120 --connect-timeout 20 --proto '=https' --tlsv1.2 "https://github.com/dragonflyoss/image-service/releases/download/${NYDUS_VERSION}/nydus-static-${NYDUS_VERSION}-linux-${TARGETARCH}.tgz" && \ tar xzf nydus-static.tgz && \ mv nydus-static/nydus-image nydus-static/nydusd nydus-static/nydusify /usr/bin/ && \ rm nydus-static.tgz diff --git a/Dockerfile.d/SHA256SUMS.d/SHA256SUMS b/Dockerfile.d/SHA256SUMS.d/SHA256SUMS new file mode 100644 index 00000000000..f9bb64f0557 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/SHA256SUMS @@ -0,0 +1,6 @@ +3edc52986c442576da856a66b59a61d16cf765359712c5ecf2d147c69f0df6e9 rootlesskit-aarch64.tar.gz +6ce9eed50f9e12f18f3e5197cf93d226bc9290185880a626ab186244593d2eed rootlesskit-armv7l.tar.gz +730ef884439e2fe15551218b05d5c4f96d96d6945db8ad7e89b1d12946408a8d rootlesskit-ppc64le.tar.gz +05da5803d0f023ec51112bbdf8967a3e12ae19544f8c101a7f08f3bb9c6548fd rootlesskit-riscv64.tar.gz +199f6bfcd0495d0b944d95f70e6fa1177ace16d801e2693fdd86fdaafa69b01a rootlesskit-s390x.tar.gz +afc52e9fa2f7a2d4bb692f675cf3d2f70f3a184f02593e8b18cfbbbc34cbfd41 rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.2 b/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.2 deleted file mode 100644 index bff0ce012f6..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.2 +++ /dev/null @@ -1,2 +0,0 @@ -70371949ac56d118e55306091640e63537069a538a97c151eb7475c07cb5a8a4 buildg-v0.5.2-linux-amd64.tar.gz -9c44a5f8ecc3035998a07e1c564338205700cf5287c723e8ccba1da2815168cc buildg-v0.5.2-linux-arm64.tar.gz \ No newline at end of file diff --git a/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.3 b/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.3 new file mode 100644 index 00000000000..0e0aa45cbf4 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/buildg-v0.5.3 @@ -0,0 +1,4 @@ +cf4c40c58ca795eeb6e75e2c6a0e5bb3a6a9c0623d51bc3b85163e5d483eeade buildg-full-v0.5.3-linux-amd64.tar.gz +47c479f2e5150c9c76294fa93a03ad20e5928f4315bf52ca8432bfb6707d4276 buildg-full-v0.5.3-linux-arm64.tar.gz +c289a454ae8673ff99acf56dec9ba97274c20d2015e80f7ac3b8eb8e4f77888f buildg-v0.5.3-linux-amd64.tar.gz +b2e244250ce7ea5c090388f2025a9c546557861d25bba7b0666aa512f01fa6cd buildg-v0.5.3-linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.21.1 b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.21.1 deleted file mode 100644 index 853b7c35172..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.21.1 +++ /dev/null @@ -1,2 +0,0 @@ -e0d83a631a48f13232fcee71cbd913e6b11dbde0a45985fa1b99af27ab97086e buildkit-v0.21.1.linux-amd64.tar.gz -7652a05f2961c386ea6e65c4701daa0e5a899a20c77596cd5f0eca02851dc1f6 buildkit-v0.21.1.linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.26.3 b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.26.3 new file mode 100644 index 00000000000..79bad2db0fe --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.26.3 @@ -0,0 +1,2 @@ +249ae16ba4be59fadb51a49ff4d632bbf37200e2b6e187fa8574f0f1bce8166b buildkit-v0.26.3.linux-amd64.tar.gz +a98829f1b1b9ec596eb424dd03f03b9c7b596edac83e6700adf83ba0cb0d5f80 buildkit-v0.26.3.linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.7.1 b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.7.1 deleted file mode 100644 index c9f57e39739..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.7.1 +++ /dev/null @@ -1,2 +0,0 @@ -1a28a0506bfe5bcdc981caf1a49eeab7e72da8321f1119b7be85f22621013098 cni-plugins-linux-amd64-v1.7.1.tgz -119fcb508d1ac2149e49a550752f9cd64d023a1d70e189b59c476e4d2bf7c497 cni-plugins-linux-arm64-v1.7.1.tgz diff --git a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.9.0 b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.9.0 new file mode 100644 index 00000000000..b23c10549fd --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.9.0 @@ -0,0 +1,2 @@ +58c03705426e929658f45a851df15a86d06ef680cacbf3f2dc127731ca265c28 cni-plugins-linux-amd64-v1.9.0.tgz +2596ef56329dd1269026f46b8df262f09ba43c92dbfb940e1e69fbccccd30a29 cni-plugins-linux-arm64-v1.9.0.tgz diff --git a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.5 b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.5 deleted file mode 100644 index faf34421cfb..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.5 +++ /dev/null @@ -1,6 +0,0 @@ -acc149d60e2fad0cff480852c82f39bdaae2eb6faa265b2028c944ec572014f9 containerd-fuse-overlayfs-2.1.5-linux-amd64.tar.gz -2c1c12a99ac16e6ad137c474517d04cc7864d26d9045f50f99a6d6e887b9c425 containerd-fuse-overlayfs-2.1.5-linux-arm-v7.tar.gz -17759de9588cda1499877cc9587189eb24731ae41edda201087fd74658ddc127 containerd-fuse-overlayfs-2.1.5-linux-arm64.tar.gz -ce0310573fd667a2fa348588b12f1867a1bad5befc79d7d39e6419a7d4687ea8 containerd-fuse-overlayfs-2.1.5-linux-ppc64le.tar.gz -e9bbb9835346d8007a6429151eb7c7b23fa1f20b85aa6d20dd3702cb5a4c038a containerd-fuse-overlayfs-2.1.5-linux-riscv64.tar.gz -c088a7eee9b75f0a759e52d1ae2c8d69d21265594070f41021a94523d1c7bab1 containerd-fuse-overlayfs-2.1.5-linux-s390x.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.7 b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.7 new file mode 100644 index 00000000000..e29367cf0d9 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.7 @@ -0,0 +1,6 @@ +d54148043c22381af89cec2a167431e40668716404a1eb682ca69dfb890376f3 containerd-fuse-overlayfs-2.1.7-linux-amd64.tar.gz +a301030391d51356065f628b5e6e5a5a8c55f1978289eb71d8f5284af7a81eda containerd-fuse-overlayfs-2.1.7-linux-arm-v7.tar.gz +94ed6c2c3bece42e0c789ea056565b64fe487de4644121ee0dfb8acd8ef9369c containerd-fuse-overlayfs-2.1.7-linux-arm64.tar.gz +1bfb1f86894b640781d837ec0f66997222b419532fae730579140dbc1c7ea858 containerd-fuse-overlayfs-2.1.7-linux-ppc64le.tar.gz +9f2ef69b06229f5357f3fc23524922cea6616663ff220979a110a7742aaffee6 containerd-fuse-overlayfs-2.1.7-linux-riscv64.tar.gz +03f61035cef5fff33c5084c55f133d0340597520d8d12112970609dff0bd1e7a containerd-fuse-overlayfs-2.1.7-linux-s390x.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.15 b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.15 deleted file mode 100644 index f3eea29017e..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.15 +++ /dev/null @@ -1,6 +0,0 @@ -a62829baa7a7d39d0a9a784d51ebd528efe226192c0a86ba6667d0fcae9129c3 fuse-overlayfs-aarch64 -7ad67a810100bebf63c41fbb621df3d552531db94d600a94f5f701b1e9f8aa5a fuse-overlayfs-armv7l -9778e1f0da1429469bcc65ea90a7504e63f0a258089b9bb1ae65105330e61808 fuse-overlayfs-ppc64le -f7a2852983b3d0a8f15c31084c215b4965d5b62b9ce1014708283dd2dd909b28 fuse-overlayfs-riscv64 -89a410a67822002c20ff21d8a9e5353ebda00d3a2f79fd99f26fb47533e253a5 fuse-overlayfs-s390x -1cd97f5ca7ac52fa192c94c1e605713cfb27d3dc417c0bef4dcfb9fb20e01e81 fuse-overlayfs-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.16 b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.16 new file mode 100644 index 00000000000..edf43283f18 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.16 @@ -0,0 +1,6 @@ +6c9ee54166fe7d33ebbfb085812585441f22ebe2a24a868d0a878d3127bcb89e fuse-overlayfs-aarch64 +fc2a73ace8eb6a0553204532de615d782cb98d86deeb0fa7b5d14347d0b95823 fuse-overlayfs-armv7l +3c07b76b432a5b4e5e0ccd986919b478d096701178617175b0c71bcce7c6f6a0 fuse-overlayfs-ppc64le +404fd7a762255d554e70849612fb6979639e1eb23a740487dbe3bac2bccc37c1 fuse-overlayfs-riscv64 +9e96cfe091b4342b8de3e239a96d5fecfb8692fbb4a201c256790c270526fd1b fuse-overlayfs-s390x +30c6b9e192600d6854e13397974c709d7cabd980b7d1a4d67defd8eb69677e91 fuse-overlayfs-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.5 b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.5 deleted file mode 100644 index 96d484fe5c7..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.5 +++ /dev/null @@ -1,6 +0,0 @@ -478c14c3195bf989cd9a8e6bd129d227d5d88f1c11418967ffdc84a0072cc7a2 rootlesskit-aarch64.tar.gz -0622e52952a848219b86b902c9bdb96e1ebe575a3015c05e7da02569e83b3a61 rootlesskit-armv7l.tar.gz -b1ec12321c54860230c5d0bbbc6d651a746ac49bce7eeb36fd1ad1e0f0048d58 rootlesskit-ppc64le.tar.gz -8ee59e518cdb5770afab49307b400f585598ed2c06b4ffc81f7c36fbeea422d6 rootlesskit-riscv64.tar.gz -2a3198947cf322357106557c58a8d5f29a664961edf290ea305c94b03521f6c8 rootlesskit-s390x.tar.gz -118208e25becd144ee7317c172fc9decce7b16174d5c1bbf80f1d1d0eacc6b5f rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.6 b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.6 new file mode 100644 index 00000000000..f9bb64f0557 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.6 @@ -0,0 +1,6 @@ +3edc52986c442576da856a66b59a61d16cf765359712c5ecf2d147c69f0df6e9 rootlesskit-aarch64.tar.gz +6ce9eed50f9e12f18f3e5197cf93d226bc9290185880a626ab186244593d2eed rootlesskit-armv7l.tar.gz +730ef884439e2fe15551218b05d5c4f96d96d6945db8ad7e89b1d12946408a8d rootlesskit-ppc64le.tar.gz +05da5803d0f023ec51112bbdf8967a3e12ae19544f8c101a7f08f3bb9c6548fd rootlesskit-riscv64.tar.gz +199f6bfcd0495d0b944d95f70e6fa1177ace16d801e2693fdd86fdaafa69b01a rootlesskit-s390x.tar.gz +afc52e9fa2f7a2d4bb692f675cf3d2f70f3a184f02593e8b18cfbbbc34cbfd41 rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.2 b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.2 deleted file mode 100644 index db7c5ae07df..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.2 +++ /dev/null @@ -1,7 +0,0 @@ -b4162d27bbbd3683ca8ee57b51a1b270c0054b3a15fcc1830a5d7c10b77ad045 SOURCE_DATE_EPOCH -c55117faa5e18345a3ee1515267f056822ff0c1897999ae5422b0114ee48df85 slirp4netns-aarch64 -f55a6c9e3ec8280e9c3cec083f07dc124e2846ce8139a9281c35013e968d7e95 slirp4netns-armv7l -7b388a9cacbd89821f7f7a6457470fcae8f51aa846162521589feb4634ec7586 slirp4netns-ppc64le -041f9fe507510de1fbb802933a6add093ff19f941185965295c81f2ba4fc9cec slirp4netns-riscv64 -aa39cf14414ae53dbff6b79dfdfa55b5ff8ac5250e2261804863cd365b33a818 slirp4netns-s390x -4d55a3658ae259e3e74bb75cf058eb05d6e39ad6bbe170ca8e94c2462bea0eb1 slirp4netns-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.3 b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.3 new file mode 100644 index 00000000000..a40e6aee074 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.3 @@ -0,0 +1,7 @@ +d0e6a13342efbedb8b7454629a0e9ce9b7a937c261034c85f46ed81af76307d8 SOURCE_DATE_EPOCH +1ca9d2f5f1fb4beb91f354653e5dad35b95c049afb264268d99a96ff2a10d903 slirp4netns-aarch64 +3e209d1c56fccbe627a038d311b233c15e8d914b30f9b981b5ed78b98e836859 slirp4netns-armv7l +4d1003a98103ee170c0fcd4aad8a5e0ba7aa2e70fbca883cbb6a39f40447c8da slirp4netns-ppc64le +06a13b398d88120097b20dace966d7dd5e2fbfd284b95a086347808df392200e slirp4netns-riscv64 +23d4a206edd6d3fc9c86f8b05c0881ff77a607b8d471f20964ad9f9c3f3176b1 slirp4netns-s390x +5618887b671a30a2f7548f2bdf7fba98a53981abc80cfd3183cd28b4dc8b2b97 slirp4netns-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 deleted file mode 100644 index e9b2bfa457c..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 +++ /dev/null @@ -1,3 +0,0 @@ -516984d13e10396f7f6090c51e4e42cc1af9a0d4b16aa81837bcdb1d5a5608d6 stargz-snapshotter-v0.16.3-linux-amd64.tar.gz -d3ac8215603cfd002901c88c568ff5c0685d6953c012fa6ff709deb50f90b023 stargz-snapshotter-v0.16.3-linux-arm64.tar.gz -f1cf855870af16a653d8acb9daa3edf84687c2c05323cb958f078fb148af3eec stargz-snapshotter.service diff --git a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.18.1 b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.18.1 new file mode 100644 index 00000000000..831e77f35a2 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.18.1 @@ -0,0 +1,3 @@ +f8f106a61b9fc797a6336d6c06435cdbf8b896f3f49fdc5288e08e87dff6bbdf stargz-snapshotter-v0.18.1-linux-amd64.tar.gz +643d04f5e97e83606b9ee129c2c33513df13a091dbc1dc084256d13a1034b749 stargz-snapshotter-v0.18.1-linux-arm64.tar.gz +f1cf855870af16a653d8acb9daa3edf84687c2c05323cb958f078fb148af3eec stargz-snapshotter.service diff --git a/Dockerfile.d/etc_containerd_config.toml b/Dockerfile.d/etc_containerd_config.toml index dccac081af4..583ebcc3d46 100644 --- a/Dockerfile.d/etc_containerd_config.toml +++ b/Dockerfile.d/etc_containerd_config.toml @@ -5,3 +5,12 @@ version = 2 [proxy_plugins.stargz] type = "snapshot" address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock" + [proxy_plugins.stargz.exports] + root = "/var/lib/containerd-stargz-grpc/" + enable_remote_snapshot_annotations = "true" +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlayfs" +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" diff --git a/Dockerfile.d/test-integration-etc_containerd_config.toml b/Dockerfile.d/test-integration-etc_containerd_config.toml index d37df58da75..0a6cc862e77 100644 --- a/Dockerfile.d/test-integration-etc_containerd_config.toml +++ b/Dockerfile.d/test-integration-etc_containerd_config.toml @@ -5,8 +5,26 @@ version = 2 [proxy_plugins.stargz] type = "snapshot" address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock" + [proxy_plugins.stargz.exports] + root = "/var/lib/containerd-stargz-grpc/" + enable_remote_snapshot_annotations = "true" # Enable soci snapshotter [proxy_plugins.soci] type = "snapshot" address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock" + [proxy_plugins.soci.exports] + root = "/var/lib/soci-snapshotter-grpc" + enable_remote_snapshot_annotations = "true" + +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlayfs" + +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "soci" + +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" diff --git a/Dockerfile.d/test-integration-rootless.sh b/Dockerfile.d/test-integration-rootless.sh index f6e243f32b5..63f383462cc 100755 --- a/Dockerfile.d/test-integration-rootless.sh +++ b/Dockerfile.d/test-integration-rootless.sh @@ -53,6 +53,15 @@ else [proxy_plugins."stargz"] type = "snapshot" address = "/run/user/$(id -u)/containerd-stargz-grpc/containerd-stargz-grpc.sock" + [proxy_plugins.stargz.exports] + root = "/home/rootless/.local/share/containerd-stargz-grpc/" + enable_remote_snapshot_annotations = "true" +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlayfs" +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" EOF systemctl --user restart containerd.service containerd-rootless-setuptool.sh -- install-ipfs --init --offline # offline ipfs daemon for testing diff --git a/EMERITUS.md b/EMERITUS.md index ed17a87f02f..5fc3c2189dd 100644 --- a/EMERITUS.md +++ b/EMERITUS.md @@ -19,3 +19,12 @@ a Reviewer of nerdctl from November 2022 to June 2024. Hanchin has made significant contributions such as the addition of [syslog driver](https://github.com/containerd/nerdctl/pull/1377) and [IPv6 networking](https://github.com/containerd/nerdctl/pull/1558). + +### Manu Gupta ([@manugupt1](https://github.com/manugupt1)) +Manu Gupta (GitHub ID [@manugupt1](https://github.com/manugupt1)) served as +a Reviewer of nerdctl from 2022 to August 2025. + +Manu has made [significant improvements](https://github.com/containerd/nerdctl/pulls?q=author%3Amanugupt1+) +especially to image and volume management, container runtime features, build system enhancements, +and CI/CD infrastructure. Notable contributions include image filtering capabilities, volume size +inspection, Docker Compose enhancements, and multi-architecture build support. diff --git a/MAINTAINERS b/MAINTAINERS index 8fbc21ebdf6..be8add49510 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -17,11 +17,13 @@ "Zheaoli", "Zheao Li", "me@manjusaka.me","6E0D D9FA BAD5 AF61 D884 01EE 878F 445D 9C6C E65E" "djdongjin", "Jin Dong", "djdongjin95@gmail.com","" "yankay", "Kay Yan", "kay.yan@daocloud.io", "" +"ChengyuZhu6","Chengyu Zhu","hudson@cyzhu.com","" # REVIEWERS # GitHub ID, Name, Email address, GPG fingerprint "jsturtevant","James Sturtevant","jstur@microsoft.com","" -"manugupt1", "Manu Gupta", "manugupt1@gmail.com","FCA9 504A 4118 EA5C F466 CC30 A5C3 A8F4 E7FE 9E10" +"Shubhranshu153","Shubharanshu Mahapatra","shubhum@amazon.com","" +"haytok","Hayato Kiwata","haytok@amazon.co.jp","B485 C5AA 6220 0A06 78FD 294D FA4F 2421 1D65 269F" # EMERITUS # See EMERITUS.md diff --git a/Makefile b/Makefile index 3544c484612..ae9d04de5d6 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,9 @@ LINT_COMMIT_RANGE ?= main..HEAD GO_BUILD_LDFLAGS ?= -s -w GO_BUILD_FLAGS ?= +BUILDTAGS ?= +GO_TAGS=$(if $(BUILDTAGS),-tags "$(strip $(BUILDTAGS))",) + ########################## # Helpers ########################## @@ -54,7 +57,7 @@ ifdef VERBOSE VERBOSE_FLAG_LONG := --verbose endif -export GO_BUILD=CGO_ENABLED=0 GOOS=$(GOOS) $(GO) -C $(MAKEFILE_DIR) build -ldflags "$(GO_BUILD_LDFLAGS) $(VERBOSE_FLAG) -X $(PACKAGE)/pkg/version.Version=$(VERSION) -X $(PACKAGE)/pkg/version.Revision=$(REVISION)" +export GO_BUILD=CGO_ENABLED=0 GOOS=$(GOOS) $(GO) -C $(MAKEFILE_DIR) build $(GO_TAGS) -ldflags "$(GO_BUILD_LDFLAGS) $(VERBOSE_FLAG) -X $(PACKAGE)/pkg/version.Version=$(VERSION) -X $(PACKAGE)/pkg/version.Revision=$(REVISION)" ifndef NO_COLOR NC := \033[0m @@ -182,7 +185,7 @@ lint-licenses-all: && GOOS=linux make lint-licenses \ && GOOS=windows make lint-licenses \ && GOOS=freebsd make lint-licenses \ - && GOOS=darwin make lint-go + && GOOS=darwin make lint-licenses $(call footer, $@) ########################## @@ -200,7 +203,7 @@ fix-go-all: && GOOS=linux make fix-go \ && GOOS=windows make fix-go \ && GOOS=freebsd make fix-go \ - && GOOS=darwin make lint-go + && GOOS=darwin make fix-go $(call footer, $@) fix-mod: @@ -214,16 +217,18 @@ fix-mod: ########################## install-dev-tools: $(call title, $@) - # golangci: v2.0.2 (2024-03-26) + # golangci: v2.4.0 (2025-08-14) # git-validation: main (2025-02-25) # ltag: main (2025-03-04) # go-licenses: v2.0.0-alpha.1 (2024-06-27) + # stubbing go-licenses with dependency upgrade due to non-compatibility with golang 1.25rc1 + # Issue: https://github.com/google/go-licenses/issues/312 @cd $(MAKEFILE_DIR) \ - && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@2b224c2cf4c9f261c22a16af7f8ca6408467f338 \ + && go install github.com/Shubhranshu153/go-licenses/v2@f8c503d1357dffb6c97ed3b94e912ab294dde24a \ + && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@43d03392d7dc3746fa776dbddd66dfcccff70651 \ && go install github.com/vbatts/git-validation@7b60e35b055dd2eab5844202ffffad51d9c93922 \ && go install github.com/containerd/ltag@66e6a514664ee2d11a470735519fa22b1a9eaabd \ - && go install github.com/google/go-licenses/v2@d01822334fba5896920a060f762ea7ecdbd086e8 \ - && go install gotest.tools/gotestsum@ac6dad9c7d87b969004f7749d1942938526c9716 + && go install gotest.tools/gotestsum@0d9599e513d70e5792bb9334869f82f6e8b53d4d @echo "Remember to add \$$HOME/go/bin to your path" $(call footer, $@) @@ -253,7 +258,7 @@ TAR_OWNER0_FLAGS=--owner=0 --group=0 TAR_FLATTEN_FLAGS=--transform 's/.*\///g' define make_artifact_full_linux - $(DOCKER) build --output type=tar,dest=$(CURDIR)/_output/nerdctl-full-$(VERSION_TRIMMED)-linux-$(1).tar --target out-full --platform $(1) --build-arg GO_VERSION -f $(MAKEFILE_DIR)/Dockerfile $(MAKEFILE_DIR) + $(DOCKER) build --secret id=github_token,env=GITHUB_TOKEN --output type=tar,dest=$(CURDIR)/_output/nerdctl-full-$(VERSION_TRIMMED)-linux-$(1).tar --target out-full --platform $(1) --build-arg GO_VERSION -f $(MAKEFILE_DIR)/Dockerfile $(MAKEFILE_DIR) gzip -9 $(CURDIR)/_output/nerdctl-full-$(VERSION_TRIMMED)-linux-$(1).tar endef @@ -268,6 +273,9 @@ artifacts: clean GOOS=linux GOARCH=arm GOARM=7 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-arm-v7.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* + GOOS=linux GOARCH=loong64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-loong64.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* + GOOS=linux GOARCH=ppc64le make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-ppc64le.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* diff --git a/README.md b/README.md index b0cb1698a95..d01e10fc3b8 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ Advanced features: - [`./docs/stargz.md`](./docs/stargz.md): Lazy-pulling using Stargz Snapshotter - [`./docs/nydus.md`](./docs/nydus.md): Lazy-pulling using Nydus Snapshotter +- [`./docs/soci.md`](./docs/soci.md): Lazy-pulling using SOCI Snapshotter - [`./docs/overlaybd.md`](./docs/overlaybd.md): Lazy-pulling using OverlayBD Snapshotter - [`./docs/ocicrypt.md`](./docs/ocicrypt.md): Running encrypted images - [`./docs/gpu.md`](./docs/gpu.md): Using GPUs inside containers diff --git a/cmd/nerdctl/builder/builder_build.go b/cmd/nerdctl/builder/builder_build.go index 8b9691fb8a3..32a2937ed75 100644 --- a/cmd/nerdctl/builder/builder_build.go +++ b/cmd/nerdctl/builder/builder_build.go @@ -19,7 +19,6 @@ package builder import ( "errors" "fmt" - "os" "strconv" "strings" @@ -263,13 +262,6 @@ func GetBuildkitHost(cmd *cobra.Command, namespace string) (string, error) { return buildkitHost, nil } - if buildkitHost := os.Getenv("BUILDKIT_HOST"); buildkitHost != "" { - if err := buildkitutil.PingBKDaemon(buildkitHost); err != nil { - return "", err - } - return buildkitHost, nil - - } return buildkitutil.GetBuildkitHost(namespace) } diff --git a/cmd/nerdctl/builder/builder_build_oci_layout_test.go b/cmd/nerdctl/builder/builder_build_oci_layout_test.go index 758675e85a1..38ae05004e5 100644 --- a/cmd/nerdctl/builder/builder_build_oci_layout_test.go +++ b/cmd/nerdctl/builder/builder_build_oci_layout_test.go @@ -27,6 +27,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -100,14 +101,13 @@ CMD ["echo", "test-nerdctl-build-context-oci-layout"]` }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Assert( t, strings.Contains( helpers.Capture("run", "--rm", data.Identifier("child")), "test-nerdctl-build-context-oci-layout", ), - info, ) }, } diff --git a/cmd/nerdctl/builder/builder_build_test.go b/cmd/nerdctl/builder/builder_build_test.go index 09ff4bfc8b4..a8d9b9fb163 100644 --- a/cmd/nerdctl/builder/builder_build_test.go +++ b/cmd/nerdctl/builder/builder_build_test.go @@ -19,7 +19,9 @@ package builder import ( "errors" "fmt" + "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -29,6 +31,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" "github.com/containerd/nerdctl/v2/pkg/platformutil" @@ -110,6 +113,7 @@ CMD ["echo", "nerdctl-build-test-string"]`, testutil.CommonImage) }, Cleanup: func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rmi", "-f", data.Identifier("ignored")) + helpers.Anyhow("rmi", "-f", data.Identifier()) }, Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), }, @@ -339,7 +343,7 @@ COPY %s /`, testFileName) }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { // Expecting testFileName to exist inside the output target directory assert.Equal(t, data.Temp().Load(testFileName), testContent, "file content is identical") }, @@ -353,7 +357,7 @@ COPY %s /`, testFileName) }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Equal(t, data.Temp().Load(testFileName), testContent, "file content is identical") }, } @@ -850,8 +854,9 @@ RUN curl -I http://google.com func TestBuildAttestation(t *testing.T) { nerdtest.Setup() - const testSBOMFileName = "sbom.spdx.json" - const testProvenanceFileName = "provenance.json" + // Using regex patterns to match SBOM and provenance files with optional platform suffix + const testSBOMFilePattern = `sbom\.spdx(?:\.[a-z0-9_]+)?\.json` + const testProvenanceFilePattern = `provenance(?:\.[a-z0-9_]+)?\.json` dockerfile := fmt.Sprintf(`FROM %s`, testutil.CommonImage) @@ -890,8 +895,18 @@ func TestBuildAttestation(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { - data.Temp().Exists("dir-for-bom", testSBOMFileName) + Output: func(stdout string, t tig.T) { + files, err := os.ReadDir(data.Temp().Path("dir-for-bom")) + assert.NilError(t, err, "failed to read directory") + + found := false + for _, file := range files { + if !file.IsDir() && regexp.MustCompile(testSBOMFilePattern).MatchString(file.Name()) { + found = true + break + } + } + assert.Assert(t, found, "no SBOM file matching pattern %s found", testSBOMFilePattern) }, } }, @@ -912,8 +927,18 @@ func TestBuildAttestation(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { - data.Temp().Exists("dir-for-prov", testProvenanceFileName) + Output: func(stdout string, t tig.T) { + files, err := os.ReadDir(data.Temp().Path("dir-for-prov")) + assert.NilError(t, err, "failed to read directory") + + found := false + for _, file := range files { + if !file.IsDir() && regexp.MustCompile(testProvenanceFilePattern).MatchString(file.Name()) { + found = true + break + } + } + assert.Assert(t, found, "no provenance file matching pattern %s found", testProvenanceFilePattern) }, } }, @@ -935,9 +960,29 @@ func TestBuildAttestation(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { - data.Temp().Exists("dir-for-attest", testSBOMFileName) - data.Temp().Exists("dir-for-attest", testProvenanceFileName) + Output: func(stdout string, t tig.T) { + // Check if any file in the directory matches the SBOM file pattern + files, err := os.ReadDir(data.Temp().Path("dir-for-attest")) + assert.NilError(t, err, "failed to read directory") + + sbomFound := false + for _, file := range files { + if !file.IsDir() && regexp.MustCompile(testSBOMFilePattern).MatchString(file.Name()) { + sbomFound = true + break + } + } + assert.Assert(t, sbomFound, "no SBOM file matching pattern %s found", testSBOMFilePattern) + + // Check if any file in the directory matches the provenance file pattern + provenanceFound := false + for _, file := range files { + if !file.IsDir() && regexp.MustCompile(testProvenanceFilePattern).MatchString(file.Name()) { + provenanceFound = true + break + } + } + assert.Assert(t, provenanceFound, "no provenance file matching pattern %s found", testProvenanceFilePattern) }, } }, diff --git a/cmd/nerdctl/builder/builder_builder_test.go b/cmd/nerdctl/builder/builder_builder_test.go index da912cc0af9..75100795e70 100644 --- a/cmd/nerdctl/builder/builder_builder_test.go +++ b/cmd/nerdctl/builder/builder_builder_test.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -152,14 +153,19 @@ CMD ["echo", "nerdctl-builder-debug-test-string"]`, testutil.CommonImage) // FIXME: this test should be rewritten to dynamically retrieve the ids, and use images // available on all platforms oldImage := testutil.BusyboxImage - oldImageSha := "7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c" + parsedOldImage, err := referenceutil.Parse(oldImage) + assert.NilError(helpers.T(), err) + oldImageSha := parsedOldImage.Digest.String() + newImage := testutil.AlpineImage - newImageSha := "ec14c7992a97fc11425907e908340c6c3d6ff602f5f13d899e6b7027c9b4133a" + parsedNewImage, err := referenceutil.Parse(newImage) + assert.NilError(helpers.T(), err) + newImageSha := parsedNewImage.Digest.String() helpers.Ensure("pull", "--quiet", oldImage) - helpers.Ensure("tag", oldImage, newImage) + helpers.Ensure("tag", oldImage, parsedNewImage.Domain+"/"+parsedNewImage.Path+":"+parsedNewImage.Tag) - dockerfile := fmt.Sprintf(`FROM %s`, newImage) + dockerfile := fmt.Sprintf(`FROM %s`, parsedNewImage.Domain+"/"+parsedNewImage.Path+":"+parsedNewImage.Tag) data.Temp().Save(dockerfile, "Dockerfile") data.Labels().Set("oldImageSha", oldImageSha) data.Labels().Set("newImageSha", newImageSha) diff --git a/cmd/nerdctl/checkpoint/checkpoint.go b/cmd/nerdctl/checkpoint/checkpoint.go new file mode 100644 index 00000000000..a17a29aeb71 --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint.go @@ -0,0 +1,55 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Annotations: map[string]string{helpers.Category: helpers.Management}, + Use: "checkpoint", + Short: "Manage checkpoints.", + RunE: helpers.UnknownSubcommandAction, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand( + createCommand(), + lsCommand(), + rmCommand(), + ) + + return cmd +} + +func lsCommand() *cobra.Command { + x := listCommand() + x.Use = "ls" + x.Aliases = []string{"list"} + return x +} +func rmCommand() *cobra.Command { + x := removeCommand() + x.Use = "rm" + x.Aliases = []string{"remove"} + return x +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_create.go b/cmd/nerdctl/checkpoint/checkpoint_create.go new file mode 100644 index 00000000000..39e8d9c3ec3 --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_create.go @@ -0,0 +1,93 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/checkpoint" +) + +func createCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "create [OPTIONS] CONTAINER CHECKPOINT", + Short: "Create a checkpoint from a running container", + Args: cobra.ExactArgs(2), + RunE: createAction, + ValidArgsFunction: createShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("leave-running", false, "Leave the container running after checkpointing") + cmd.Flags().String("checkpoint-dir", "", "Checkpoint directory") + return cmd +} + +func processCreateFlags(cmd *cobra.Command) (types.CheckpointCreateOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.CheckpointCreateOptions{}, err + } + + leaveRunning, err := cmd.Flags().GetBool("leave-running") + if err != nil { + return types.CheckpointCreateOptions{}, err + } + checkpointDir, err := cmd.Flags().GetString("checkpoint-dir") + if err != nil { + return types.CheckpointCreateOptions{}, err + } + if checkpointDir == "" { + checkpointDir = filepath.Join(globalOptions.DataRoot, "checkpoints") + } + + return types.CheckpointCreateOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + LeaveRunning: leaveRunning, + CheckpointDir: checkpointDir, + }, nil +} + +func createAction(cmd *cobra.Command, args []string) error { + createOptions, err := processCreateFlags(cmd) + if err != nil { + return err + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), createOptions.GOptions.Namespace, createOptions.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + err = checkpoint.Create(ctx, client, args[0], args[1], createOptions) + if err != nil { + return err + } + + return nil +} + +func createShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_create_linux_test.go b/cmd/nerdctl/checkpoint/checkpoint_create_linux_test.go new file mode 100644 index 00000000000..cb2f0c2da1c --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_create_linux_test.go @@ -0,0 +1,127 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestCheckpointCreateErrors(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.SubTests = []*test.Case{ + { + Description: "too-few-arguments", + Command: test.Command("checkpoint", "create", "too-few-arguments"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "too-many-arguments", + Command: test.Command("checkpoint", "create", "too", "many", "arguments"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "invalid-container-id", + Command: test.Command("checkpoint", "create", "foo", "bar"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("error creating checkpoint for container: foo")}, + } + }, + }, + } + + testCase.Run(t) +} + +func TestCheckpointCreate(t *testing.T) { + const ( + checkpointName = "checkpoint-bar" + checkpointDir = "/dir/foo" + ) + testCase := nerdtest.Setup() + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.NoParallel = true + testCase.SubTests = []*test.Case{ + { + Description: "leave-running=true", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("container-running"), testutil.CommonImage, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container-running")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("checkpoint", "create", "--leave-running", "--checkpoint-dir", checkpointDir, data.Identifier("container-running"), checkpointName+"running") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Equals(checkpointName + "running\n"), + } + }, + }, + { + Description: "leave-running=false", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("container-exit"), testutil.CommonImage, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container-exit")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("checkpoint", "create", "--checkpoint-dir", checkpointDir, data.Identifier("container-exit"), checkpointName+"exit") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Equals(checkpointName + "exit\n"), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_list.go b/cmd/nerdctl/checkpoint/checkpoint_list.go new file mode 100644 index 00000000000..ed49bbf6e12 --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_list.go @@ -0,0 +1,95 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/checkpoint" +) + +func listCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "list [OPTIONS] CONTAINER", + Short: "List checkpoints for a container", + Args: cobra.ExactArgs(1), + RunE: listAction, + ValidArgsFunction: listShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().String("checkpoint-dir", "", "Checkpoint directory") + return cmd +} + +func processListFlags(cmd *cobra.Command) (types.CheckpointListOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.CheckpointListOptions{}, err + } + + checkpointDir, err := cmd.Flags().GetString("checkpoint-dir") + if err != nil { + return types.CheckpointListOptions{}, err + } + if checkpointDir == "" { + checkpointDir = globalOptions.DataRoot + "/checkpoints" + } + + return types.CheckpointListOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + CheckpointDir: checkpointDir, + }, nil +} + +func listAction(cmd *cobra.Command, args []string) error { + listOptions, err := processListFlags(cmd) + if err != nil { + return err + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), listOptions.GOptions.Namespace, listOptions.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + checkpoints, err := checkpoint.List(ctx, client, args[0], listOptions) + if err != nil { + return err + } + + w := tabwriter.NewWriter(listOptions.Stdout, 4, 8, 4, ' ', 0) + fmt.Fprintln(w, "CHECKPOINT NAME") + + for _, cp := range checkpoints { + fmt.Fprintln(w, cp.Name) + } + + return w.Flush() +} + +func listShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_list_linux_test.go b/cmd/nerdctl/checkpoint/checkpoint_list_linux_test.go new file mode 100644 index 00000000000..05eb176e122 --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_list_linux_test.go @@ -0,0 +1,106 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestCheckpointListErrors(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + + testCase.SubTests = []*test.Case{ + { + Description: "too-few-arguments", + Command: test.Command("checkpoint", "list"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ExitCode: 1} + }, + }, + { + Description: "too-many-arguments", + Command: test.Command("checkpoint", "list", "too", "many", "arguments"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ExitCode: 1} + }, + }, + { + Description: "invalid-container-id", + Command: test.Command("checkpoint", "list", "no-such-container"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("error list checkpoint for container: no-such-container")}, + } + }, + }, + } + + testCase.Run(t) +} + +func TestCheckpointList(t *testing.T) { + const checkpointName = "checkpoint-list" + + testCase := nerdtest.Setup() + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.NoParallel = true + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", "infinity") + helpers.Ensure("checkpoint", "create", data.Identifier(), checkpointName) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("checkpoint", "list", data.Identifier()) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + // First line is header, second should include the checkpoint name + Output: expect.Contains("CHECKPOINT NAME\n" + checkpointName + "\n"), + } + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_remove.go b/cmd/nerdctl/checkpoint/checkpoint_remove.go new file mode 100644 index 00000000000..2149076f58c --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_remove.go @@ -0,0 +1,87 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/checkpoint" +) + +func removeCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "rm [OPTIONS] CONTAINER CHECKPOINT", + Short: "Remove a checkpoint", + Args: cobra.ExactArgs(2), + RunE: removeAction, + ValidArgsFunction: removeShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().String("checkpoint-dir", "", "Checkpoint directory") + return cmd +} + +func processRemoveFlags(cmd *cobra.Command) (types.CheckpointRemoveOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.CheckpointRemoveOptions{}, err + } + + checkpointDir, err := cmd.Flags().GetString("checkpoint-dir") + if err != nil { + return types.CheckpointRemoveOptions{}, err + } + if checkpointDir == "" { + checkpointDir = filepath.Join(globalOptions.DataRoot, "checkpoints") + } + + return types.CheckpointRemoveOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + CheckpointDir: checkpointDir, + }, nil +} + +func removeAction(cmd *cobra.Command, args []string) error { + removeOptions, err := processRemoveFlags(cmd) + if err != nil { + return err + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), removeOptions.GOptions.Namespace, removeOptions.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + err = checkpoint.Remove(ctx, client, args[0], args[1], removeOptions) + if err != nil { + return err + } + + return nil +} + +func removeShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_remove_linux_test.go b/cmd/nerdctl/checkpoint/checkpoint_remove_linux_test.go new file mode 100644 index 00000000000..e43e0b5500a --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_remove_linux_test.go @@ -0,0 +1,128 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestCheckpointRemoveErrors(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.SubTests = []*test.Case{ + { + Description: "too-few-arguments", + Command: test.Command("checkpoint", "rm", "too-few-arguments"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "too-many-arguments", + Command: test.Command("checkpoint", "rm", "too", "many", "arguments"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "invalid-container-id", + Command: test.Command("checkpoint", "rm", "foo", "bar"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("error removing checkpoint for container: foo")}, + } + }, + }, + } + + testCase.Run(t) +} + +func TestCheckpointRemove(t *testing.T) { + const ( + checkpointName = "checkpoint-remove" + checkpointDir = "/dir/remove" + ) + testCase := nerdtest.Setup() + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.NoParallel = true + testCase.SubTests = []*test.Case{ + { + Description: "remove-existing", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("container-running-remove"), testutil.CommonImage, "sleep", "infinity") + helpers.Ensure("checkpoint", "create", "--checkpoint-dir", checkpointDir, data.Identifier("container-running-remove"), checkpointName) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container-running-remove")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("checkpoint", "rm", "--checkpoint-dir", checkpointDir, data.Identifier("container-running-remove"), checkpointName) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Equals(""), + } + }, + }, + { + Description: "remove-nonexistent-checkpoint", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("container-clean-remove"), testutil.CommonImage, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container-clean-remove")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("checkpoint", "rm", "--checkpoint-dir", checkpointDir, data.Identifier("container-clean-remove"), checkpointName) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("checkpoint " + checkpointName + " does not exist for container")}, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/checkpoint/checkpoint_test.go b/cmd/nerdctl/checkpoint/checkpoint_test.go new file mode 100644 index 00000000000..e32a997e219 --- /dev/null +++ b/cmd/nerdctl/checkpoint/checkpoint_test.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/completion/completion.go b/cmd/nerdctl/completion/completion.go index 7718c1bb063..e12e2375a40 100644 --- a/cmd/nerdctl/completion/completion.go +++ b/cmd/nerdctl/completion/completion.go @@ -164,6 +164,7 @@ func Platforms(cmd *cobra.Command, args []string, toComplete string) ([]string, "riscv64", "ppc64le", "s390x", + "loong64", "386", "arm", // alias of "linux/arm/v7" "linux/arm/v6", // "arm/v6" is invalid (interpreted as OS="arm", Arch="v7") diff --git a/cmd/nerdctl/completion/completion_unix.go b/cmd/nerdctl/completion/completion_unix.go index af0b8698ce2..64438047fa2 100644 --- a/cmd/nerdctl/completion/completion_unix.go +++ b/cmd/nerdctl/completion/completion_unix.go @@ -38,6 +38,49 @@ func IPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string return []string{"default", "host-local", "dhcp"}, cobra.ShellCompDirectiveNoFileComp } +func NetworkOptions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + driver, _ := cmd.Flags().GetString("driver") + if driver == "" { + driver = "bridge" + } + + var candidates []string + switch driver { + case "bridge": + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + "ip-masq=", + "com.docker.network.bridge.enable_ip_masquerade=", + } + case "macvlan": + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + "mode=bridge", + "macvlan_mode=bridge", + "parent=", + } + case "ipvlan": + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + "mode=l2", + "mode=l3", + "ipvlan_mode=l2", + "ipvlan_mode=l3", + "parent=", + } + default: + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + "parent=", + } + } + return candidates, cobra.ShellCompDirectiveNoSpace +} + func NamespaceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { diff --git a/cmd/nerdctl/completion/completion_windows.go b/cmd/nerdctl/completion/completion_windows.go index 020e0594926..b46d4c3fb5d 100644 --- a/cmd/nerdctl/completion/completion_windows.go +++ b/cmd/nerdctl/completion/completion_windows.go @@ -38,3 +38,25 @@ func NetworkDrivers(cmd *cobra.Command, args []string, toComplete string) ([]str func IPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"default"}, cobra.ShellCompDirectiveNoFileComp } + +func NetworkOptions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + driver, _ := cmd.Flags().GetString("driver") + if driver == "" { + driver = "nat" + } + + var candidates []string + switch driver { + case "nat": + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + } + default: + candidates = []string{ + "mtu=", + "com.docker.network.driver.mtu=", + } + } + return candidates, cobra.ShellCompDirectiveNoSpace +} diff --git a/cmd/nerdctl/compose/compose_build_linux_test.go b/cmd/nerdctl/compose/compose_build_linux_test.go index 2c967a331e2..cfa51e27400 100644 --- a/cmd/nerdctl/compose/compose_build_linux_test.go +++ b/cmd/nerdctl/compose/compose_build_linux_test.go @@ -29,7 +29,7 @@ import ( ) func TestComposeBuild(t *testing.T) { - dockerfile := "FROM " + testutil.AlpineImage + dockerfile := "FROM " + testutil.CommonImage testCase := nerdtest.Setup() @@ -39,6 +39,7 @@ func TestComposeBuild(t *testing.T) { // Make sure we shard the image name to something unique to the test to avoid conflicts with other tests imageSvc0 := data.Identifier("svc0") imageSvc1 := data.Identifier("svc1") + imageSvc2 := data.Identifier("svc2") // We are not going to run them, so, ports conflicts should not matter here dockerComposeYAML := fmt.Sprintf(` @@ -46,16 +47,18 @@ services: svc0: build: . image: %s - ports: - - 8080:80 depends_on: - svc1 svc1: build: . image: %s - ports: - - 8081:80 -`, imageSvc0, imageSvc1) + svc2: + image: %s + build: + context: . + dockerfile_inline: | + FROM %s +`, imageSvc0, imageSvc1, imageSvc2, testutil.CommonImage) data.Temp().Save(dockerComposeYAML, "compose.yaml") data.Temp().Save(dockerfile, "Dockerfile") @@ -63,6 +66,7 @@ services: data.Labels().Set("composeYaml", data.Temp().Path("compose.yaml")) data.Labels().Set("imageSvc0", imageSvc0) data.Labels().Set("imageSvc1", imageSvc1) + data.Labels().Set("imageSvc2", imageSvc2) } testCase.SubTests = []*test.Case{ @@ -80,22 +84,41 @@ services: Output: expect.All( expect.Contains(data.Labels().Get("imageSvc0")), expect.DoesNotContain(data.Labels().Get("imageSvc1")), + expect.DoesNotContain(data.Labels().Get("imageSvc2")), + ), + } + }, + }, + { + Description: "build svc2", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYaml"), "build", "svc2") + }, + + Command: test.Command("images"), + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: expect.All( + expect.Contains(data.Labels().Get("imageSvc2")), + expect.DoesNotContain(data.Labels().Get("imageSvc1")), ), } }, }, { - Description: "build svc0 and svc1", + Description: "build svc0, svc1, svc2", NoParallel: true, Setup: func(data test.Data, helpers test.Helpers) { - helpers.Ensure("compose", "-f", data.Labels().Get("composeYaml"), "build", "svc0", "svc1") + helpers.Ensure("compose", "-f", data.Labels().Get("composeYaml"), "build", "svc0", "svc1", "svc2") }, Command: test.Command("images"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: expect.Contains(data.Labels().Get("imageSvc0"), data.Labels().Get("imageSvc1")), + Output: expect.Contains(data.Labels().Get("imageSvc0"), data.Labels().Get("imageSvc1"), data.Labels().Get("imageSvc2")), } }, }, @@ -126,7 +149,7 @@ services: testCase.Cleanup = func(data test.Data, helpers test.Helpers) { if data.Labels().Get("imageSvc0") != "" { - helpers.Anyhow("rmi", data.Labels().Get("imageSvc0"), data.Labels().Get("imageSvc1")) + helpers.Anyhow("rmi", data.Labels().Get("imageSvc0"), data.Labels().Get("imageSvc1"), data.Labels().Get("imageSvc2")) } } diff --git a/cmd/nerdctl/compose/compose_config_test.go b/cmd/nerdctl/compose/compose_config_test.go index e9f16c6a92d..bb439f7026f 100644 --- a/cmd/nerdctl/compose/compose_config_test.go +++ b/cmd/nerdctl/compose/compose_config_test.go @@ -24,16 +24,19 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeConfig(t *testing.T) { - const dockerComposeYAML = ` + dockerComposeYAML := fmt.Sprintf(` services: hello: - image: alpine:3.13 -` + image: %s +`, testutil.CommonImage) + testCase := nerdtest.Setup() testCase.Setup = func(data test.Data, helpers test.Helpers) { @@ -111,7 +114,7 @@ services: testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Assert(t, data.Labels().Get("hash") != stdout, "hash should be different") }, } diff --git a/cmd/nerdctl/compose/compose_cp_linux_test.go b/cmd/nerdctl/compose/compose_cp_linux_test.go index 7d5dea8502c..b6fd2aea25b 100644 --- a/cmd/nerdctl/compose/compose_cp_linux_test.go +++ b/cmd/nerdctl/compose/compose_cp_linux_test.go @@ -24,6 +24,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -31,8 +32,6 @@ import ( func TestComposeCopy(t *testing.T) { var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -79,7 +78,7 @@ services: }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { copied := data.Temp().Load("test-file2") assert.Equal(t, copied, testFileContent) }, diff --git a/cmd/nerdctl/compose/compose_create_linux_test.go b/cmd/nerdctl/compose/compose_create_linux_test.go index c1a94dfd2c6..26557dd7ca0 100644 --- a/cmd/nerdctl/compose/compose_create_linux_test.go +++ b/cmd/nerdctl/compose/compose_create_linux_test.go @@ -17,27 +17,31 @@ package compose import ( + "errors" "fmt" + "path/filepath" + "regexp" "strings" "testing" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeCreate(t *testing.T) { var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s -`, testutil.AlpineImage) +`, testutil.CommonImage) testCase := nerdtest.Setup() @@ -66,7 +70,7 @@ services: Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "ps", "svc0", "-a") }, - Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout, info string, t *testing.T) { + Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(stdout, "created") || strings.Contains(stdout, "Created"), "stdout should contain `created`") @@ -87,8 +91,6 @@ services: func TestComposeCreateDependency(t *testing.T) { var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -125,7 +127,7 @@ services: Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "ps", "svc0", "-a") }, - Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout, info string, t *testing.T) { + Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(stdout, "created") || strings.Contains(stdout, "Created"), "stdout should contain `created`") @@ -137,7 +139,7 @@ services: Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "ps", "svc1", "-a") }, - Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout, info string, t *testing.T) { + Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(stdout, "created") || strings.Contains(stdout, "Created"), "stdout should contain `created`") @@ -149,63 +151,204 @@ services: } func TestComposeCreatePull(t *testing.T) { + testCase := nerdtest.Setup() - base := testutil.NewBase(t) - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase.NoParallel = true + testCase.Require = nerdtest.Private + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` services: svc0: image: %s -`, testutil.AlpineImage) - - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() - - // `compose create --pull never` should fail: no such image - base.Cmd("rmi", "-f", testutil.AlpineImage).Run() - base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--pull", "never").AssertFail() - // `compose create --pull missing(default)|always` should succeed: image is pulled and container is created - base.Cmd("rmi", "-f", testutil.AlpineImage).Run() - base.ComposeCmd("-f", comp.YAMLFullPath(), "create").AssertOK() - base.Cmd("rmi", "-f", testutil.AlpineImage).Run() - base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--pull", "always").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") +`, testutil.CommonImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("composeYAML", composePath) + } + + testCase.SubTests = []*test.Case{ + { + Description: "compose create --pull never fails when image missing", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "create", "--pull", "never") + }, + Expected: test.Expects(1, nil, nil), + }, + { + Description: "compose create --pull missing (default) pulls and creates a container", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "create") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps", "svc0", "-a") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile(`Created|created`))), + }, + { + Description: "compose create --pull always pulls and creates a container", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "create", "--pull", "always") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps", "svc0", "-a") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile(`Created|created`))), + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) +} + +func TestComposeCreatePullInvalidOption(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` +services: + svc0: + image: %s +`, testutil.CommonImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + data.Labels().Set("composeYAML", composePath) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + // nerver isn't never. + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "create", "--pull", "nerver") + } + + testCase.Expected = test.Expects(1, []error{errors.New(`invalid --pull option \"nerver\"`)}, nil) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if path := data.Labels().Get("composeYAML"); path != "" { + helpers.Anyhow("compose", "-f", path, "down", "-v") + } + } + + testCase.Run(t) } func TestComposeCreateBuild(t *testing.T) { - const imageSvc0 = "composebuild_svc0" + testCase := nerdtest.Setup() + + testCase.NoParallel = true + testCase.Require = require.All( + nerdtest.Private, + nerdtest.Build, + ) - dockerComposeYAML := fmt.Sprintf(` + testCase.Setup = func(data test.Data, helpers test.Helpers) { + imageSvc0 := data.Identifier("composebuild_svc0") + composeYAML := fmt.Sprintf(` services: svc0: build: . image: %s `, imageSvc0) + dockerfile := fmt.Sprintf(`FROM %s`, testutil.CommonImage) - dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + data.Temp().Save(dockerfile, "Dockerfile") - testutil.RequiresBuild(t) - testutil.RegisterBuildCacheCleanup(t) - base := testutil.NewBase(t) + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - comp.WriteFile("Dockerfile", dockerfile) - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("imageName", imageSvc0) + } - defer base.Cmd("rmi", imageSvc0).Run() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + testCase.SubTests = []*test.Case{ + { + Description: "compose create --no-build fails when image needs to be built", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "create", "--no-build") + }, + Expected: test.Expects(1, nil, nil), + }, + { + Description: "compose create --build builds image and creates container", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "create", "--build") + helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "images", "svc0").Run( + &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, data.Labels().Get("imageName"))) + }, + }, + ) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps", "svc0", "-a") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile(`Created|created`))), + }, + } - // `compose create --no-build` should fail if service image needs build - base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--no-build").AssertFail() - // `compose create --build` should succeed: image is built and container is created - base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--build").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "svc0").AssertOutContains(imageSvc0) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + helpers.Anyhow("rmi", "-f", data.Labels().Get("imageName")) + helpers.Anyhow("builder", "prune", "--all", "--force") + } + + testCase.Run(t) +} + +func TestComposeCreateWritesConfigHashLabel(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` +services: + svc0: + image: %s +`, testutil.CommonImage) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("containerName", serviceparser.DefaultContainerName(projectName, "svc0", "1")) + + helpers.Ensure("compose", "-f", composePath, "create") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", "--format", "{{json .Config.Labels}}", data.Labels().Get("containerName")) + } + + testCase.Expected = test.Expects(0, nil, expect.Contains("com.docker.compose.config-hash")) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if path := data.Labels().Get("composeYAML"); path != "" { + helpers.Anyhow("compose", "-f", path, "down", "-v") + } + } + + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_down_linux_test.go b/cmd/nerdctl/compose/compose_down_linux_test.go index b995631d6b6..2eea0c01827 100644 --- a/cmd/nerdctl/compose/compose_down_linux_test.go +++ b/cmd/nerdctl/compose/compose_down_linux_test.go @@ -19,82 +19,136 @@ package compose import ( "fmt" "testing" - "time" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeDownRemoveUsedNetwork(t *testing.T) { - base := testutil.NewBase(t) - - var ( - dockerComposeYAMLOrphan = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + dockerComposeYAMLOrphan := fmt.Sprintf(` services: test: image: %s command: "sleep infinity" -`, testutil.AlpineImage) +`, testutil.CommonImage) - dockerComposeYAMLFull = fmt.Sprintf(` + dockerComposeYAMLFull := fmt.Sprintf(` %s orphan: image: %s command: "sleep infinity" -`, dockerComposeYAMLOrphan, testutil.AlpineImage) - ) - - compOrphan := testutil.NewComposeDir(t, dockerComposeYAMLOrphan) - defer compOrphan.CleanUp() - compFull := testutil.NewComposeDir(t, dockerComposeYAMLFull) - defer compFull.CleanUp() - - projectName := fmt.Sprintf("nerdctl-compose-test-%d", time.Now().Unix()) - t.Logf("projectName=%q", projectName) - - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "down", "--remove-orphans").AssertOK() - - base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "down", "-v").AssertCombinedOutContains("in use") - +`, dockerComposeYAMLOrphan, testutil.CommonImage) + + composeOrphanPath := data.Temp().Save(dockerComposeYAMLOrphan, "compose-orphan.yaml") + composeFullPath := data.Temp().Save(dockerComposeYAMLFull, "compose-full.yaml") + + projectName := data.Identifier("project") + t.Logf("projectName=%q", projectName) + + testContainer := serviceparser.DefaultContainerName(projectName, "test", "1") + orphanContainer := serviceparser.DefaultContainerName(projectName, "orphan", "1") + + data.Labels().Set("composeOrphan", composeOrphanPath) + data.Labels().Set("composeFull", composeFullPath) + data.Labels().Set("projectName", projectName) + + helpers.Ensure("compose", "-p", projectName, "-f", composeFullPath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, testContainer) + nerdtest.EnsureContainerStarted(helpers, orphanContainer) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeOrphan"), "down", "-v") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{ + fmt.Errorf("in use"), + }, + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if composeFull := data.Labels().Get("composeFull"); composeFull != "" { + helpers.Anyhow("compose", "-p", data.Labels().Get("projectName"), "-f", composeFull, "down", "--remove-orphans") + } + } + + testCase.Run(t) } func TestComposeDownRemoveOrphans(t *testing.T) { - base := testutil.NewBase(t) - - var ( - dockerComposeYAMLOrphan = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + dockerComposeYAMLOrphan := fmt.Sprintf(` services: test: image: %s command: "sleep infinity" -`, testutil.AlpineImage) +`, testutil.CommonImage) - dockerComposeYAMLFull = fmt.Sprintf(` + dockerComposeYAMLFull := fmt.Sprintf(` %s orphan: image: %s command: "sleep infinity" -`, dockerComposeYAMLOrphan, testutil.AlpineImage) - ) - - compOrphan := testutil.NewComposeDir(t, dockerComposeYAMLOrphan) - defer compOrphan.CleanUp() - compFull := testutil.NewComposeDir(t, dockerComposeYAMLFull) - defer compFull.CleanUp() - - projectName := compFull.ProjectName() - t.Logf("projectName=%q", projectName) - - orphanContainer := serviceparser.DefaultContainerName(projectName, "orphan", "1") - - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "down", "-v").Run() - - base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "down", "--remove-orphans").AssertOK() - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps", "-a").AssertOutNotContains(orphanContainer) +`, dockerComposeYAMLOrphan, testutil.CommonImage) + + composeOrphanPath := data.Temp().Save(dockerComposeYAMLOrphan, "compose-orphan.yaml") + composeFullPath := data.Temp().Save(dockerComposeYAMLFull, "compose-full.yaml") + + projectName := data.Identifier("project") + t.Logf("projectName=%q", projectName) + + testContainer := serviceparser.DefaultContainerName(projectName, "test", "1") + orphanContainer := serviceparser.DefaultContainerName(projectName, "orphan", "1") + + data.Labels().Set("composeOrphan", composeOrphanPath) + data.Labels().Set("composeFull", composeFullPath) + data.Labels().Set("projectName", projectName) + data.Labels().Set("orphanContainer", orphanContainer) + + helpers.Ensure("compose", "-p", projectName, "-f", composeFullPath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, testContainer) + nerdtest.EnsureContainerStarted(helpers, orphanContainer) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeOrphan"), "down", "--remove-orphans") + } + + testCase.Expected = test.Expects(0, nil, nil) + + testCase.SubTests = []*test.Case{ + { + Description: "orphan container removed", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeFull"), "ps", "-a") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(data.Labels().Get("orphanContainer")), + } + }, + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if composeFull := data.Labels().Get("composeFull"); composeFull != "" { + helpers.Anyhow("compose", "-p", data.Labels().Get("projectName"), "-f", composeFull, "down", "-v") + } + } + + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_exec_linux_test.go b/cmd/nerdctl/compose/compose_exec_linux_test.go index 0f86c447de4..d0ee72403b5 100644 --- a/cmd/nerdctl/compose/compose_exec_linux_test.go +++ b/cmd/nerdctl/compose/compose_exec_linux_test.go @@ -34,8 +34,6 @@ import ( func TestComposeExec(t *testing.T) { dockerComposeYAML := fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -179,8 +177,6 @@ services: func TestComposeExecTTY(t *testing.T) { const expectedOutput = "speed 38400 baud" dockerComposeYAML := fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -267,8 +263,6 @@ services: func TestComposeExecWithIndex(t *testing.T) { dockerComposeYAML := fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -285,6 +279,11 @@ services: data.Labels().Set("projectName", strings.ToLower(filepath.Base(data.Temp().Dir()))) helpers.Ensure("compose", "-f", yamlPath, "up", "-d", "svc0") + + // Make sure all containers are started so that /etc/hosts is consistent. + for _, index := range []string{"1", "2", "3"} { + nerdtest.EnsureContainerStarted(helpers, fmt.Sprintf("%s-svc0-%s", data.Labels().Get("projectName"), index)) + } } testCase.Cleanup = func(data test.Data, helpers test.Helpers) { diff --git a/cmd/nerdctl/compose/compose_images_linux_test.go b/cmd/nerdctl/compose/compose_images_linux_test.go index f9f7f475186..f0feba15812 100644 --- a/cmd/nerdctl/compose/compose_images_linux_test.go +++ b/cmd/nerdctl/compose/compose_images_linux_test.go @@ -17,24 +17,26 @@ package compose import ( - "encoding/json" "fmt" - "strings" "testing" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeImages(t *testing.T) { - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s - ports: - - 8080:80 + container_name: wordpress environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -44,6 +46,7 @@ services: - wordpress:/var/www/html db: image: %s + container_name: db environment: MYSQL_DATABASE: exampledb MYSQL_USER: exampleuser @@ -57,95 +60,71 @@ volumes: db: `, testutil.WordpressImage, testutil.MariaDBImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - wordpressImageName := strings.Split(testutil.WordpressImage, ":")[0] - dbImageName := strings.Split(testutil.MariaDBImage, ":")[0] - - // check one service image - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "db").AssertOutContains(dbImageName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "db").AssertOutNotContains(wordpressImageName) - - // check all service images - base.ComposeCmd("-f", comp.YAMLFullPath(), "images").AssertOutContains(dbImageName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "images").AssertOutContains(wordpressImageName) -} + wordpressImageName, _ := referenceutil.Parse(testutil.WordpressImage) + dbImageName, _ := referenceutil.Parse(testutil.MariaDBImage) -func TestComposeImagesJson(t *testing.T) { - base := testutil.NewBase(t) - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() -services: - wordpress: - image: %s - container_name: wordpress - ports: - - 8080:80 - environment: - WORDPRESS_DB_HOST: db - WORDPRESS_DB_USER: exampleuser - WORDPRESS_DB_PASSWORD: examplepass - WORDPRESS_DB_NAME: exampledb - volumes: - - wordpress:/var/www/html - db: - image: %s - container_name: db - environment: - MYSQL_DATABASE: exampledb - MYSQL_USER: exampleuser - MYSQL_PASSWORD: examplepass - MYSQL_RANDOM_ROOT_PASSWORD: '1' - volumes: - - db:/var/lib/mysql - -volumes: - wordpress: - db: -`, testutil.WordpressImage, testutil.MariaDBImage) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + data.Labels().Set("composeYaml", data.Temp().Path("compose.yaml")) + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + } - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - assertHandler := func(svc string, count int, fields ...string) func(stdout string) error { - return func(stdout string) error { - // 1. check json output can be unmarshalled back to printables. - var printables []composeContainerPrintable - if err := json.Unmarshal([]byte(stdout), &printables); err != nil { - return fmt.Errorf("[service: %s]failed to unmarshal json output from `compose images`: %s", svc, stdout) - } - // 2. check #printables matches expected count. - if len(printables) != count { - return fmt.Errorf("[service: %s]unmarshal generates %d printables, expected %d: %s", svc, len(printables), count, stdout) - } - // 3. check marshalled json string has all expected substrings. - for _, field := range fields { - if !strings.Contains(stdout, field) { - return fmt.Errorf("[service: %s]marshalled json output doesn't have expected string (%s): %s", svc, field, stdout) - } - } - return nil - } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") } - // check other formats are not supported - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "--format", "yaml").AssertFail() - // check all services are up (can be marshalled and unmarshalled) - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "--format", "json"). - AssertOutWithFunc(assertHandler("all", 2, `"ContainerName":"wordpress"`, `"ContainerName":"db"`)) + testCase.SubTests = []*test.Case{ + { + Description: "images db", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "images", "db") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains(dbImageName.Name()), + expect.DoesNotContain(wordpressImageName.Name()), + )), + }, + { + Description: "images", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "images") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Contains(dbImageName.Name(), wordpressImageName.Name())), + }, + { + Description: "images --format yaml", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "images", "--format", "yaml") + }, + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), + }, + { + Description: "images --format json", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "images", "--format", "json") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.JSON([]composeContainerPrintable{}, func(printables []composeContainerPrintable, t tig.T) { + assert.Equal(t, len(printables), 2) + }), + expect.Contains(`"ContainerName":"wordpress"`, `"ContainerName":"db"`), + )), + }, + { + Description: "images --format json wordpress", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "images", "--format", "json", "wordpress") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.JSON([]composeContainerPrintable{}, func(printables []composeContainerPrintable, t tig.T) { + assert.Equal(t, len(printables), 1) + }), + expect.Contains(`"ContainerName":"wordpress"`), + )), + }, + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "--format", "json", "wordpress"). - AssertOutWithFunc(assertHandler("wordpress", 1, `"ContainerName":"wordpress"`)) + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_kill_linux_test.go b/cmd/nerdctl/compose/compose_kill_linux_test.go index 6571950a62e..1d2813e7c1b 100644 --- a/cmd/nerdctl/compose/compose_kill_linux_test.go +++ b/cmd/nerdctl/compose/compose_kill_linux_test.go @@ -18,23 +18,27 @@ package compose import ( "fmt" + "path/filepath" + "regexp" "testing" - "time" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeKill(t *testing.T) { - base := testutil.NewBase(t) - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + dockerComposeYAML := fmt.Sprintf(` services: wordpress: image: %s - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -58,17 +62,52 @@ volumes: db: `, testutil.WordpressImage, testutil.MariaDBImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(dockerComposeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + wordpressContainerName := serviceparser.DefaultContainerName(projectName, "wordpress", "1") + dbContainerName := serviceparser.DefaultContainerName(projectName, "db", "1") + + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("wordpressContainer", wordpressContainerName) + data.Labels().Set("dbContainer", dbContainerName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, wordpressContainerName) + nerdtest.EnsureContainerStarted(helpers, dbContainerName) + } + + testCase.SubTests = []*test.Case{ + { + Description: "kill db container and exit with 137", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "kill", "db") + nerdtest.EnsureContainerExited(helpers, data.Labels().Get("dbContainer"), expect.ExitCodeSigkill) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps", "db", "-a") + }, + // Docker Compose v1: "Exit 137", v2: "exited (137)" + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Match(regexp.MustCompile(` 137|\(137\)`))), + }, + { + Description: "wordpress container is still running", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps", "wordpress") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Match(regexp.MustCompile("Up|running"))), + }, + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "kill", "db").AssertOK() - time.Sleep(3 * time.Second) - // Docker Compose v1: "Exit 137", v2: "exited (137)" - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny(" 137", "(137)") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_pause_linux_test.go b/cmd/nerdctl/compose/compose_pause_linux_test.go index 381e8686d6b..14624633f39 100644 --- a/cmd/nerdctl/compose/compose_pause_linux_test.go +++ b/cmd/nerdctl/compose/compose_pause_linux_test.go @@ -31,8 +31,6 @@ func TestComposePauseAndUnpause(t *testing.T) { } var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s diff --git a/cmd/nerdctl/compose/compose_port.go b/cmd/nerdctl/compose/compose_port.go index f08b5e9eed7..b4f7b5453d7 100644 --- a/cmd/nerdctl/compose/compose_port.go +++ b/cmd/nerdctl/compose/compose_port.go @@ -88,11 +88,18 @@ func portAction(cmd *cobra.Command, args []string) error { return err } + dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) + if err != nil { + return err + } + po := composer.PortOptions{ ServiceName: args[0], Index: index, Port: port, Protocol: protocol, + DataStore: dataStore, + Namespace: globalOptions.Namespace, } return c.Port(ctx, cmd.OutOrStdout(), po) diff --git a/cmd/nerdctl/compose/compose_port_linux_test.go b/cmd/nerdctl/compose/compose_port_linux_test.go index 15946557ad2..514740be130 100644 --- a/cmd/nerdctl/compose/compose_port_linux_test.go +++ b/cmd/nerdctl/compose/compose_port_linux_test.go @@ -20,15 +20,17 @@ import ( "fmt" "testing" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposePort(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -55,8 +57,6 @@ func TestComposePortFailure(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -79,3 +79,42 @@ services: base.ComposeCmd("-f", comp.YAMLFullPath(), "port", "--protocol", "udp", "svc0", "10000").AssertFail() base.ComposeCmd("-f", comp.YAMLFullPath(), "port", "--protocol", "tcp", "svc0", "10001").AssertFail() } + +// TestComposeMultiplePorts tests whether it is possible to allocate a large +// number of ports. (https://github.com/containerd/nerdctl/issues/4027) +func TestComposeMultiplePorts(t *testing.T) { + var dockerComposeYAML = fmt.Sprintf(` +services: + svc0: + image: %s + command: "sleep infinity" + ports: + - '32000-32060:32000-32060' +`, testutil.AlpineImage) + + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + compYamlPath := data.Temp().Save(dockerComposeYAML, "compose.yaml") + data.Labels().Set("composeYaml", compYamlPath) + + helpers.Ensure("compose", "-f", compYamlPath, "up", "-d") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down", "-v") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #4027 - Allocate a large number of ports.", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYaml"), "port", "svc0", "32000") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Contains("0.0.0.0:32000")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/compose/compose_ps.go b/cmd/nerdctl/compose/compose_ps.go index badee1755b9..f73b3407d09 100644 --- a/cmd/nerdctl/compose/compose_ps.go +++ b/cmd/nerdctl/compose/compose_ps.go @@ -29,9 +29,9 @@ import ( "github.com/containerd/containerd/v2/core/runtime/restart" "github.com/containerd/errdefs" "github.com/containerd/go-cni" - "github.com/containerd/log" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/cmd/compose" "github.com/containerd/nerdctl/v2/pkg/containerutil" @@ -183,9 +183,9 @@ func psAction(cmd *cobra.Command, args []string) error { var p composeContainerPrintable var err error if format == "json" { - p, err = composeContainerPrintableJSON(ctx, container) + p, err = composeContainerPrintableJSON(ctx, container, globalOptions) } else { - p, err = composeContainerPrintableTab(ctx, container) + p, err = composeContainerPrintableTab(ctx, container, globalOptions) } if err != nil { return err @@ -234,7 +234,7 @@ func psAction(cmd *cobra.Command, args []string) error { // composeContainerPrintableTab constructs composeContainerPrintable with fields // only for console output. -func composeContainerPrintableTab(ctx context.Context, container containerd.Container) (composeContainerPrintable, error) { +func composeContainerPrintableTab(ctx context.Context, container containerd.Container, gOptions types.GlobalCommandOptions) (composeContainerPrintable, error) { info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata) if err != nil { return composeContainerPrintable{}, err @@ -251,6 +251,18 @@ func composeContainerPrintableTab(ctx context.Context, container containerd.Cont if err != nil { return composeContainerPrintable{}, err } + dataStore, err := clientutil.DataStore(gOptions.DataRoot, gOptions.Address) + if err != nil { + return composeContainerPrintable{}, err + } + containerLabels, err := container.Labels(ctx) + if err != nil { + return composeContainerPrintable{}, err + } + ports, err := portutil.LoadPortMappings(dataStore, gOptions.Namespace, info.ID, containerLabels) + if err != nil { + return composeContainerPrintable{}, err + } return composeContainerPrintable{ Name: info.Labels[labels.Name], @@ -258,13 +270,13 @@ func composeContainerPrintableTab(ctx context.Context, container containerd.Cont Command: formatter.InspectContainerCommandTrunc(spec), Service: info.Labels[labels.ComposeService], State: status, - Ports: formatter.FormatPorts(info.Labels), + Ports: formatter.FormatPorts(ports), }, nil } // composeContainerPrintableJSON constructs composeContainerPrintable with fields // only for json output and compatible docker output. -func composeContainerPrintableJSON(ctx context.Context, container containerd.Container) (composeContainerPrintable, error) { +func composeContainerPrintableJSON(ctx context.Context, container containerd.Container, gOptions types.GlobalCommandOptions) (composeContainerPrintable, error) { info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata) if err != nil { return composeContainerPrintable{}, err @@ -294,6 +306,18 @@ func composeContainerPrintableJSON(ctx context.Context, container containerd.Con if err != nil { return composeContainerPrintable{}, err } + dataStore, err := clientutil.DataStore(gOptions.DataRoot, gOptions.Address) + if err != nil { + return composeContainerPrintable{}, err + } + containerLabels, err := container.Labels(ctx) + if err != nil { + return composeContainerPrintable{}, err + } + portMappings, err := portutil.LoadPortMappings(dataStore, gOptions.Namespace, info.ID, containerLabels) + if err != nil { + return composeContainerPrintable{}, err + } return composeContainerPrintable{ ID: container.ID(), @@ -305,7 +329,7 @@ func composeContainerPrintableJSON(ctx context.Context, container containerd.Con State: state, Health: "", ExitCode: exitCode, - Publishers: formatPublishers(info.Labels), + Publishers: formatPublishers(portMappings), }, nil } @@ -321,7 +345,7 @@ type PortPublisher struct { // formatPublishers parses and returns docker-compatible []PortPublisher from // label map. If an error happens, an empty slice is returned. -func formatPublishers(labelMap map[string]string) []PortPublisher { +func formatPublishers(portMappings []cni.PortMapping) []PortPublisher { mapper := func(pm cni.PortMapping) PortPublisher { return PortPublisher{ URL: pm.HostIP, @@ -332,12 +356,8 @@ func formatPublishers(labelMap map[string]string) []PortPublisher { } var dockerPorts []PortPublisher - if portMappings, err := portutil.ParsePortsLabel(labelMap); err == nil { - for _, p := range portMappings { - dockerPorts = append(dockerPorts, mapper(p)) - } - } else { - log.L.Error(err.Error()) + for _, p := range portMappings { + dockerPorts = append(dockerPorts, mapper(p)) } return dockerPorts } diff --git a/cmd/nerdctl/compose/compose_ps_linux_test.go b/cmd/nerdctl/compose/compose_ps_linux_test.go index df6f1d3cfe5..c6ebb1de8aa 100644 --- a/cmd/nerdctl/compose/compose_ps_linux_test.go +++ b/cmd/nerdctl/compose/compose_ps_linux_test.go @@ -32,14 +32,10 @@ import ( func TestComposePs(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s container_name: wordpress_container - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -64,7 +60,7 @@ services: volumes: wordpress: db: -`, testutil.WordpressImage, testutil.MariaDBImage, testutil.AlpineImage) +`, testutil.WordpressImage, testutil.MariaDBImage, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() projectName := comp.ProjectName() @@ -100,9 +96,9 @@ volumes: time.Sleep(3 * time.Second) base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutWithFunc(assertHandler("wordpress_container", testutil.WordpressImage)) base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutWithFunc(assertHandler("db_container", testutil.MariaDBImage)) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps").AssertOutNotContains(testutil.AlpineImage) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "alpine", "-a").AssertOutWithFunc(assertHandler("alpine_container", testutil.AlpineImage)) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "-a", "--filter", "status=exited").AssertOutWithFunc(assertHandler("alpine_container", testutil.AlpineImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps").AssertOutNotContains(testutil.CommonImage) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "alpine", "-a").AssertOutWithFunc(assertHandler("alpine_container", testutil.CommonImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "-a", "--filter", "status=exited").AssertOutWithFunc(assertHandler("alpine_container", testutil.CommonImage)) base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--services", "-a").AssertOutContainsAll("wordpress\n", "db\n", "alpine\n") } @@ -112,8 +108,6 @@ func TestComposePsJSON(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s diff --git a/cmd/nerdctl/compose/compose_pull_linux_test.go b/cmd/nerdctl/compose/compose_pull_linux_test.go index 64e267baa24..e0c79325326 100644 --- a/cmd/nerdctl/compose/compose_pull_linux_test.go +++ b/cmd/nerdctl/compose/compose_pull_linux_test.go @@ -26,14 +26,10 @@ import ( func TestComposePullWithService(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser diff --git a/cmd/nerdctl/compose/compose_restart_linux_test.go b/cmd/nerdctl/compose/compose_restart_linux_test.go index 6d5fe1fdedc..1e2ca2c5c61 100644 --- a/cmd/nerdctl/compose/compose_restart_linux_test.go +++ b/cmd/nerdctl/compose/compose_restart_linux_test.go @@ -26,13 +26,9 @@ import ( func TestComposeRestart(t *testing.T) { base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser diff --git a/cmd/nerdctl/compose/compose_rm_linux_test.go b/cmd/nerdctl/compose/compose_rm_linux_test.go index 948ea9e119d..af876eb2bed 100644 --- a/cmd/nerdctl/compose/compose_rm_linux_test.go +++ b/cmd/nerdctl/compose/compose_rm_linux_test.go @@ -18,23 +18,23 @@ package compose import ( "fmt" + "regexp" "testing" - "time" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeRemove(t *testing.T) { - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -58,27 +58,71 @@ volumes: db: `, testutil.WordpressImage, testutil.MariaDBImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - // no stopped containers - base.ComposeCmd("-f", comp.YAMLFullPath(), "rm", "-f").AssertOK() - time.Sleep(3 * time.Second) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") - // remove one stopped service - base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "wordpress").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "rm", "-f", "wordpress").AssertOK() - time.Sleep(3 * time.Second) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutNotContains("wordpress") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") - // remove all services with `--stop` - base.ComposeCmd("-f", comp.YAMLFullPath(), "rm", "-f", "-s").AssertOK() - time.Sleep(3 * time.Second) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutNotContains("db") + testCase := nerdtest.Setup() + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + data.Labels().Set("yamlPath", data.Temp().Path("compose.yaml")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "All services are still up", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "rm", "-f") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + wp := helpers.Capture("compose", "-f", data.Labels().Get("yamlPath"), "ps", "wordpress") + db := helpers.Capture("compose", "-f", data.Labels().Get("yamlPath"), "ps", "db") + comp := expect.Match(regexp.MustCompile("Up|running")) + comp(wp, t) + comp(db, t) + }, + } + }, + }, + { + Description: "Remove stopped service", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("compose", "-f", data.Labels().Get("yamlPath"), "stop", "wordpress") + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "rm", "-f") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + wp := helpers.Capture("compose", "-f", data.Labels().Get("yamlPath"), "ps", "wordpress") + db := helpers.Capture("compose", "-f", data.Labels().Get("yamlPath"), "ps", "db") + expect.DoesNotContain("wordpress")(wp, t) + expect.Match(regexp.MustCompile("Up|running"))(db, t) + }, + } + }, + }, + { + Description: "Remove all services with stop", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "rm", "-f", "-s") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + db := helpers.Capture("compose", "-f", data.Labels().Get("yamlPath"), "ps", "db") + expect.DoesNotContain("db")(db, t) + }, + } + }, + }, + } + + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_run_linux_test.go b/cmd/nerdctl/compose/compose_run_linux_test.go index bd606299bfe..c68d9fa258d 100644 --- a/cmd/nerdctl/compose/compose_run_linux_test.go +++ b/cmd/nerdctl/compose/compose_run_linux_test.go @@ -40,13 +40,12 @@ func TestComposeRun(t *testing.T) { const expectedOutput = "speed 38400 baud" dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s entrypoint: - stty -`, testutil.AlpineImage) +`, testutil.CommonImage) testCase := nerdtest.Setup() @@ -120,7 +119,6 @@ func TestComposeRunWithServicePorts(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: web: image: %s @@ -144,7 +142,7 @@ services: }() checkNginx := func() error { - resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 10, false) + resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 5, false) if err != nil { return err } @@ -182,7 +180,6 @@ func TestComposeRunWithPublish(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: web: image: %s @@ -204,7 +201,7 @@ services: }() checkNginx := func() error { - resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 10, false) + resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 5, false) if err != nil { return err } @@ -242,7 +239,6 @@ func TestComposeRunWithEnv(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s @@ -250,7 +246,7 @@ services: - sh - -c - "echo $$FOO" -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -274,14 +270,13 @@ func TestComposeRunWithUser(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s entrypoint: - id - -u -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -303,7 +298,6 @@ func TestComposeRunWithLabel(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s @@ -312,7 +306,7 @@ services: - "dummy log" labels: - "foo=bar" -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -341,13 +335,12 @@ func TestComposeRunWithArgs(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s entrypoint: - echo -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -371,13 +364,12 @@ func TestComposeRunWithEntrypoint(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s entrypoint: - stty # should be changed -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -399,13 +391,12 @@ func TestComposeRunWithVolume(t *testing.T) { containerName := testutil.Identifier(t) dockerComposeYAML := fmt.Sprintf(` -version: '3.1' services: alpine: image: %s entrypoint: - stty # no meaning, just put any command -`, testutil.AlpineImage) +`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -489,7 +480,7 @@ services: `, imageSvc0, keyPair.PublicKey, keyPair.PrivateKey, imageSvc1, keyPair.PrivateKey, imageSvc2) - dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) + dockerfile := fmt.Sprintf(`FROM %s`, testutil.CommonImage) comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() diff --git a/cmd/nerdctl/compose/compose_start.go b/cmd/nerdctl/compose/compose_start.go index c945f52adb7..08a64d2f91d 100644 --- a/cmd/nerdctl/compose/compose_start.go +++ b/cmd/nerdctl/compose/compose_start.go @@ -28,8 +28,10 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/labels" ) @@ -52,6 +54,8 @@ func startAction(cmd *cobra.Command, args []string) error { return err } + nerdctlCmd, nerdctlArgs := helpers.GlobalFlags(cmd) + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) if err != nil { return err @@ -86,7 +90,7 @@ func startAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("service %q has no container to start", svcName) } - if err := startContainers(ctx, client, containers); err != nil { + if err := startContainers(ctx, client, containers, &globalOptions, nerdctlCmd, nerdctlArgs); err != nil { return err } } @@ -94,7 +98,7 @@ func startAction(cmd *cobra.Command, args []string) error { return nil } -func startContainers(ctx context.Context, client *containerd.Client, containers []containerd.Container) error { +func startContainers(ctx context.Context, client *containerd.Client, containers []containerd.Container, globalOptions *types.GlobalCommandOptions, nerdctlCmd string, nerdctlArgs []string) error { eg, ctx := errgroup.WithContext(ctx) for _, c := range containers { c := c @@ -112,7 +116,7 @@ func startContainers(ctx context.Context, client *containerd.Client, containers } // in compose, always disable attach - if err := containerutil.Start(ctx, c, false, false, client, ""); err != nil { + if err := containerutil.Start(ctx, c, false, false, client, "", "", (*config.Config)(globalOptions), nerdctlCmd, nerdctlArgs); err != nil { return err } info, err := c.Info(ctx, containerd.WithoutRefreshedMetadata) diff --git a/cmd/nerdctl/compose/compose_start_linux_test.go b/cmd/nerdctl/compose/compose_start_linux_test.go index 11c1581cd92..2bd5dc11af8 100644 --- a/cmd/nerdctl/compose/compose_start_linux_test.go +++ b/cmd/nerdctl/compose/compose_start_linux_test.go @@ -18,16 +18,19 @@ package compose import ( "fmt" + "regexp" "testing" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeStart(t *testing.T) { - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -37,50 +40,68 @@ services: command: "sleep infinity" `, testutil.CommonImage, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + testCase := nerdtest.Setup() - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } - // calling `compose start` after all services up has no effect. - base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertOK() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "start") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "stop", "--timeout", "1", "svc0") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "kill", "svc1") + } - // `compose start`` can start a stopped/killed service container - base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "--timeout", "1", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "kill", "svc1").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Up", "running") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1").AssertOutContainsAny("Up", "running") -} + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Temp().Path("compose.yaml"), "start") + } -func TestComposeStartFailWhenServicePause(t *testing.T) { - base := testutil.NewBase(t) - switch base.Info().CgroupDriver { - case "none", "": - t.Skip("requires cgroup (for pausing)") + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: nil, + Output: func(stdout string, t tig.T) { + svc0 := helpers.Capture("compose", "-f", data.Temp().Path("compose.yaml"), "ps", "svc0") + svc1 := helpers.Capture("compose", "-f", data.Temp().Path("compose.yaml"), "ps", "svc1") + comp := expect.Match(regexp.MustCompile("Up|running")) + comp(svc0, t) + comp(svc1, t) + }, + } } - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase.Run(t) +} +func TestComposeStartFailWhenServicePause(t *testing.T) { + var dockerComposeYAML = fmt.Sprintf(` services: svc0: image: %s command: "sleep infinity" `, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.CGroup + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "pause", "svc0") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Temp().Path("compose.yaml"), "start") + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + testCase.Expected = test.Expects(expect.ExitCodeGenericFail, nil, nil) - // `compose start` cannot start a paused service container - base.ComposeCmd("-f", comp.YAMLFullPath(), "pause", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertFail() + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_stop_linux_test.go b/cmd/nerdctl/compose/compose_stop_linux_test.go index e10b16ff7b2..ac346b90507 100644 --- a/cmd/nerdctl/compose/compose_stop_linux_test.go +++ b/cmd/nerdctl/compose/compose_stop_linux_test.go @@ -18,22 +18,22 @@ package compose import ( "fmt" + "regexp" "testing" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeStop(t *testing.T) { - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: wordpress: image: %s - ports: - - 8080:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -57,21 +57,50 @@ volumes: db: `, testutil.WordpressImage, testutil.MariaDBImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - // stop should (only) stop the given service. - base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") - - // `--timeout` arg should work properly. - base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "--timeout", "5", "wordpress").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress", "-a").AssertOutContainsAny("Exit", "exited") - + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + data.Labels().Set("yamlPath", data.Temp().Path("compose.yaml")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } + + testCase.SubTests = []*test.Case{ + { + Description: "stop db", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("yamlPath"), "stop", "db") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "ps", "db", "-a") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile("Exit|exited"))), + }, + { + Description: "wordpress is still running", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "ps", "wordpress") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile("Up|running"))), + }, + { + Description: "stop wordpress", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("yamlPath"), "stop", "--timeout", "5", "wordpress") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "ps", "wordpress", "-a") + }, + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile("Exit|exited"))), + }, + } + + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_top_linux_test.go b/cmd/nerdctl/compose/compose_top_linux_test.go index a0474c51b0b..9620aa113c1 100644 --- a/cmd/nerdctl/compose/compose_top_linux_test.go +++ b/cmd/nerdctl/compose/compose_top_linux_test.go @@ -20,20 +20,16 @@ import ( "fmt" "testing" - "github.com/containerd/nerdctl/v2/pkg/infoutil" - "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeTop(t *testing.T) { - if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { - t.Skip("test skipped for rootless containers on cgroup v1") - } - - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' - services: svc0: image: %s @@ -42,15 +38,36 @@ services: image: %s `, testutil.CommonImage, testutil.NginxAlpineImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + testCase := nerdtest.Setup() + + testCase.Require = require.All(nerdtest.CgroupsAccessible) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") + helpers.Ensure("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") + data.Labels().Set("yamlPath", data.Temp().Path("compose.yaml")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } + + testCase.SubTests = []*test.Case{ + { + Description: "svc0 contains sleep infinity", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "top", "svc0") + }, + Expected: test.Expects(0, nil, expect.Contains("sleep infinity")), + }, + { + Description: "svc1 contains sleep nginx", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("yamlPath"), "top", "svc1") + }, + Expected: test.Expects(0, nil, expect.Contains("nginx")), + }, + } - // a running container should have the process command in output - base.ComposeCmd("-f", comp.YAMLFullPath(), "top", "svc0").AssertOutContains("sleep infinity") - base.ComposeCmd("-f", comp.YAMLFullPath(), "top", "svc1").AssertOutContains("nginx") + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_up_linux_test.go b/cmd/nerdctl/compose/compose_up_linux_test.go index 3d7597adf88..6e0476b859c 100644 --- a/cmd/nerdctl/compose/compose_up_linux_test.go +++ b/cmd/nerdctl/compose/compose_up_linux_test.go @@ -19,36 +19,44 @@ package compose import ( "fmt" "io" - "os" + "path/filepath" + "strconv" "strings" "testing" - "time" "github.com/docker/go-connections/nat" "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" - "github.com/containerd/log" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" - "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" ) func TestComposeUp(t *testing.T) { - base := testutil.NewBase(t) - helpers.ComposeUp(t, base, fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() -services: + testCase.Setup = func(data test.Data, helpers test.Helpers) { + hostPort, err := portlock.Acquire(0) + if err != nil { + helpers.T().Log(fmt.Sprintf("Failed to acquire port: %v", err)) + helpers.T().FailNow() + } + composeYAML := fmt.Sprintf(` +services: wordpress: image: %s restart: always ports: - - 8080:80 + - %d:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -56,7 +64,6 @@ services: WORDPRESS_DB_NAME: exampledb volumes: - wordpress:/var/www/html - db: image: %s restart: always @@ -71,54 +78,142 @@ services: volumes: wordpress: db: -`, testutil.WordpressImage, testutil.MariaDBImage)) +`, testutil.WordpressImage, hostPort, testutil.MariaDBImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + wordpressContainerName := serviceparser.DefaultContainerName(projectName, "wordpress", "1") + dbContainerName := serviceparser.DefaultContainerName(projectName, "db", "1") + + data.Labels().Set("hostPort", strconv.Itoa(hostPort)) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("projectName", projectName) + data.Labels().Set("wordpressContainerName", wordpressContainerName) + data.Labels().Set("dbContainerName", dbContainerName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, wordpressContainerName) + nerdtest.EnsureContainerStarted(helpers, dbContainerName) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.Contains(data.Labels().Get("wordpressContainerName")), + expect.Contains(data.Labels().Get("dbContainerName")), + ), + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + if p := data.Labels().Get("hostPort"); p != "" { + if port, err := strconv.Atoi(p); err == nil { + _ = portlock.Release(port) + } + } + if projectName := data.Labels().Get("projectName"); projectName != "" { + helpers.Command("volume", "inspect", fmt.Sprintf("%s_db", projectName)).Run(&test.Expected{ExitCode: 1}) + helpers.Command("network", "inspect", fmt.Sprintf("%s_default", projectName)).Run(&test.Expected{ExitCode: 1}) + } + } + + testCase.Run(t) } func TestComposeUpBuild(t *testing.T) { - testutil.RequiresBuild(t) - testutil.RegisterBuildCacheCleanup(t) - base := testutil.NewBase(t) + testCase := nerdtest.Setup() - const dockerComposeYAML = ` + testCase.Require = nerdtest.Build + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + hostPort, err := portlock.Acquire(0) + if err != nil { + helpers.T().Log(fmt.Sprintf("Failed to acquire port: %v", err)) + helpers.T().FailNow() + } + + composeYAML := fmt.Sprintf(` services: web: build: . ports: - - 8080:80 -` - dockerfile := fmt.Sprintf(`FROM %s + - %d:80 +`, hostPort) + dockerfile := fmt.Sprintf(`FROM %s COPY index.html /usr/share/nginx/html/index.html `, testutil.NginxAlpineImage) - indexHTML := t.Name() + indexHTML := data.Identifier("indexHTML") - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + data.Temp().Save(dockerfile, "Dockerfile") + data.Temp().Save(indexHTML, "index.html") - comp.WriteFile("Dockerfile", dockerfile) - comp.WriteFile("index.html", indexHTML) + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d", "--build").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + data.Labels().Set("hostPort", strconv.Itoa(hostPort)) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("indexHTML", data.Temp().Path("index.html")) + + helpers.Ensure("compose", "-f", composePath, "up", "-d", "--build") + nerdtest.EnsureContainerStarted(helpers, serviceparser.DefaultContainerName(projectName, "web", "1")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "HTTP request to the web container", + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + host := fmt.Sprintf("http://127.0.0.1:%s", data.Labels().Get("hostPort")) + resp, err := nettestutil.HTTPGet(host, 5, false) + assert.NilError(t, err) + respBody, err := io.ReadAll(resp.Body) + assert.NilError(t, err) + t.Log(fmt.Sprintf("respBody=%q", respBody)) + assert.Assert(t, strings.Contains(string(respBody), data.Labels().Get("indexHTML"))) + }, + } + }, + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + helpers.Anyhow("builder", "prune", "--all", "--force") + if portStr := data.Labels().Get("hostPort"); portStr != "" { + port, _ := strconv.Atoi(portStr) + _ = portlock.Release(port) + } + } - resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 50, false) - assert.NilError(t, err) - respBody, err := io.ReadAll(resp.Body) - assert.NilError(t, err) - t.Logf("respBody=%q", respBody) - assert.Assert(t, strings.Contains(string(respBody), indexHTML)) + testCase.Run(t) } func TestComposeUpNetWithStaticIP(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("Static IP assignment is not supported rootless mode yet.") - } - base := testutil.NewBase(t) - staticIP := "172.20.0.12" - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + ) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + staticIP := "10.4.255.254" + subnet := "10.4.255.0/24" + var composeYAML = fmt.Sprintf(` services: svc0: image: %s @@ -130,33 +225,55 @@ networks: net0: ipam: config: - - subnet: 172.20.0.0/24 -`, testutil.NginxAlpineImage, staticIP) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - svc0 := serviceparser.DefaultContainerName(projectName, "svc0", "1") - inspectCmd := base.Cmd("inspect", svc0, "--format", "\"{{range .NetworkSettings.Networks}} {{.IPAddress}}{{end}}\"") - result := inspectCmd.Run() - stdoutContent := result.Stdout() + result.Stderr() - assert.Assert(inspectCmd.Base.T, result.ExitCode == 0, stdoutContent) - if !strings.Contains(stdoutContent, staticIP) { - log.L.Errorf("test failed, the actual container ip is %s", stdoutContent) - t.Fail() - return + - subnet: %s +`, testutil.NginxAlpineImage, staticIP, subnet) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + containerName := serviceparser.DefaultContainerName(projectName, "svc0", "1") + + data.Labels().Set("staticIP", staticIP) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("containerName", containerName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, containerName) + } + + testCase.SubTests = []*test.Case{ + { + Description: "static IP is assigned to container", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Labels().Get("containerName"), "--format", "\"{{range .NetworkSettings.Networks}} {{.IPAddress}}{{end}}\"") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, data.Labels().Get("staticIP"))) + }, + } + }, + }, } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) } func TestComposeUpMultiNet(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` services: svc0: image: %s @@ -179,136 +296,269 @@ networks: net1: {} net2: {} `, testutil.NginxAlpineImage, testutil.NginxAlpineImage, testutil.NginxAlpineImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + svc0 := serviceparser.DefaultContainerName(projectName, "svc0", "1") + svc1 := serviceparser.DefaultContainerName(projectName, "svc1", "1") + svc2 := serviceparser.DefaultContainerName(projectName, "svc2", "1") + + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("svc0", svc0) + data.Labels().Set("svc1", svc1) + data.Labels().Set("svc2", svc2) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, svc0) + nerdtest.EnsureContainerStarted(helpers, svc1) + nerdtest.EnsureContainerStarted(helpers, svc2) + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + testCase.SubTests = []*test.Case{ + { + Description: "svc0 can ping itself", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc0"), "ping", "-c", "1", "svc0") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "svc0 can ping svc1", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc0"), "ping", "-c", "1", "svc1") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "svc0 can ping svc2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc0"), "ping", "-c", "1", "svc2") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "svc1 can ping svc0", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc1"), "ping", "-c", "1", "svc0") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "svc2 can ping svc0", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc2"), "ping", "-c", "1", "svc0") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "svc1 cannot ping svc2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("svc1"), "ping", "-c", "1", "svc2") + }, + Expected: test.Expects(1, nil, nil), + }, + } - svc0 := serviceparser.DefaultContainerName(projectName, "svc0", "1") - svc1 := serviceparser.DefaultContainerName(projectName, "svc1", "1") - svc2 := serviceparser.DefaultContainerName(projectName, "svc2", "1") + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } - base.Cmd("exec", svc0, "ping", "-c", "1", "svc0").AssertOK() - base.Cmd("exec", svc0, "ping", "-c", "1", "svc1").AssertOK() - base.Cmd("exec", svc0, "ping", "-c", "1", "svc2").AssertOK() - base.Cmd("exec", svc1, "ping", "-c", "1", "svc0").AssertOK() - base.Cmd("exec", svc2, "ping", "-c", "1", "svc0").AssertOK() - base.Cmd("exec", svc1, "ping", "-c", "1", "svc2").AssertFail() + testCase.Run(t) } func TestComposeUpOsEnvVar(t *testing.T) { - base := testutil.NewBase(t) - const containerName = "nginxAlpine" - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + + testCase.Env = map[string]string{ + "ADDRESS": "0.0.0.0", + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + const containerName = "nginxAlpine" + + hostPort, err := portlock.Acquire(0) + if err != nil { + helpers.T().Log(fmt.Sprintf("Failed to acquire port: %v", err)) + helpers.T().FailNow() + } + var composeYAML = fmt.Sprintf(` services: svc1: image: %s container_name: %s ports: - - ${ADDRESS:-127.0.0.1}:8080:80 -`, testutil.NginxAlpineImage, containerName) + - ${ADDRESS:-127.0.0.1}:%d:80 +`, testutil.NginxAlpineImage, containerName, hostPort) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") - base.Env = append(base.Env, "ADDRESS=0.0.0.0") + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + data.Labels().Set("containerName", containerName) + data.Labels().Set("hostPort", strconv.Itoa(hostPort)) + data.Labels().Set("composeYAML", composePath) - inspect := base.InspectContainer(containerName) - inspect80TCP := (*inspect.NetworkSettings.Ports)["80/tcp"] - expected := nat.PortBinding{ - HostIP: "0.0.0.0", - HostPort: "8080", + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, containerName) } - assert.Equal(base.T, expected, inspect80TCP[0]) + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "inspect", data.Labels().Get("containerName")) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) { + assert.Equal(t, 1, len(dc), "unexpected number of containers") + inspect80TCP := (*dc[0].NetworkSettings.Ports)["80/tcp"] + assert.Assert(t, len(inspect80TCP) > 0, "no host bindings for 80/tcp") + expected := nat.PortBinding{ + HostIP: "0.0.0.0", + HostPort: data.Labels().Get("hostPort"), + } + assert.Equal(t, expected, inspect80TCP[0]) + }), + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) } func TestComposeUpDotEnvFile(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = ` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = ` services: svc3: image: ghcr.io/stargz-containers/nginx:$TAG ` - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + data.Temp().Save(`TAG=1.19-alpine-org`, ".env") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("composeYAML", composePath) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d") + } - envFile := `TAG=1.19-alpine-org` - comp.WriteFile(".env", envFile) + testCase.Expected = test.Expects(0, nil, nil) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + + testCase.Run(t) } func TestComposeUpEnvFileNotFoundError(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = ` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = ` services: svc4: image: ghcr.io/stargz-containers/nginx:$TAG ` - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + data.Temp().Save(`TAG=1.19-alpine-org`, "envFile") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("composeYAML", composePath) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + // env-file is relative to the current working directory and not the project directory + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "--env-file", "envFile", "up", "-d") + } + + testCase.Expected = test.Expects(1, nil, nil) - envFile := `TAG=1.19-alpine-org` - comp.WriteFile("envFile", envFile) + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } - //env-file is relative to the current working directory and not the project directory - base.ComposeCmd("-f", comp.YAMLFullPath(), "--env-file", "envFile", "up", "-d").AssertFail() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + testCase.Run(t) } func TestComposeUpWithScale(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` services: test: image: %s command: "sleep infinity" -`, testutil.AlpineImage) +`, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d", "--scale", "test=2").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps").AssertOutContains(serviceparser.DefaultContainerName(projectName, "test", "2")) + test1 := serviceparser.DefaultContainerName(projectName, "test", "1") + test2 := serviceparser.DefaultContainerName(projectName, "test", "2") + + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("test1", test1) + data.Labels().Set("test2", test2) + + helpers.Ensure("compose", "-f", composePath, "up", "-d", "--scale", "test=2") + nerdtest.EnsureContainerStarted(helpers, test1) + nerdtest.EnsureContainerStarted(helpers, test2) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "ps") + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.Contains(data.Labels().Get("test1")), + expect.Contains(data.Labels().Get("test2")), + ), + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) } func TestComposeIPAMConfig(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` services: foo: image: %s @@ -319,87 +569,223 @@ networks: ipam: config: - subnet: 10.1.100.0/24 -`, testutil.AlpineImage) +`, testutil.CommonImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + fooContainer := serviceparser.DefaultContainerName(projectName, "foo", "1") - base.Cmd("inspect", "-f", `{{json .NetworkSettings.Networks }}`, serviceparser.DefaultContainerName(projectName, "foo", "1")).AssertOutContains("10.1.100.") + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("fooContainer", fooContainer) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, fooContainer) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", "-f", "{{json .NetworkSettings.Networks }}", data.Labels().Get("fooContainer")) + } + + testCase.Expected = test.Expects(0, nil, expect.Contains("10.1.100.")) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) } func TestComposeUpRemoveOrphans(t *testing.T) { - base := testutil.NewBase(t) - - var ( - dockerComposeYAMLOrphan = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var ( + dockerComposeYAMLOrphan = fmt.Sprintf(` services: test: image: %s command: "sleep infinity" -`, testutil.AlpineImage) +`, testutil.CommonImage) - dockerComposeYAMLFull = fmt.Sprintf(` + dockerComposeYAMLFull = fmt.Sprintf(` %s orphan: image: %s command: "sleep infinity" -`, dockerComposeYAMLOrphan, testutil.AlpineImage) - ) +`, dockerComposeYAMLOrphan, testutil.CommonImage) + ) + + composeOrphanPath := data.Temp().Save(dockerComposeYAMLOrphan, "compose-orphan.yaml") + composeFullPath := data.Temp().Save(dockerComposeYAMLFull, "compose-full.yaml") + + projectName := data.Identifier("project") + t.Logf("projectName=%q", projectName) + + testContainer := serviceparser.DefaultContainerName(projectName, "test", "1") + orphanContainer := serviceparser.DefaultContainerName(projectName, "orphan", "1") + + data.Labels().Set("composeOrphanPath", composeOrphanPath) + data.Labels().Set("composeFullPath", composeFullPath) + data.Labels().Set("projectName", projectName) + data.Labels().Set("orphanContainer", orphanContainer) + + helpers.Ensure("compose", "-p", projectName, "-f", composeFullPath, "up", "-d") + helpers.Ensure("compose", "-p", projectName, "-f", composeOrphanPath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, testContainer) + nerdtest.EnsureContainerStarted(helpers, orphanContainer) + + helpers.Command("compose", "-p", projectName, "-f", composeFullPath, "ps").Run( + &test.Expected{ + ExitCode: 0, + Output: expect.Contains(orphanContainer), + }, + ) + helpers.Ensure("compose", "-p", projectName, "-f", composeOrphanPath, "up", "-d", "--remove-orphans") + } - compOrphan := testutil.NewComposeDir(t, dockerComposeYAMLOrphan) - defer compOrphan.CleanUp() - compFull := testutil.NewComposeDir(t, dockerComposeYAMLFull) - defer compFull.CleanUp() + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeFullPath"), "ps") + } - projectName := fmt.Sprintf("nerdctl-compose-test-%d", time.Now().Unix()) - t.Logf("projectName=%q", projectName) + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(data.Labels().Get("orphanContainer")), + } + } - orphanContainer := serviceparser.DefaultContainerName(projectName, "orphan", "1") + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeOrphanPath") != "" { + helpers.Anyhow("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeOrphanPath"), "down", "-v") + } + if data.Labels().Get("composeFullPath") != "" { + helpers.Anyhow("compose", "-p", data.Labels().Get("projectName"), "-f", data.Labels().Get("composeFullPath"), "down", "-v") + } + } - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "down", "-v").Run() - base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "up", "-d").AssertOK() - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps").AssertOutContains(orphanContainer) - base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "up", "-d", "--remove-orphans").AssertOK() - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps").AssertOutNotContains(orphanContainer) + testCase.Run(t) } func TestComposeUpIdempotent(t *testing.T) { - base := testutil.NewBase(t) - - var dockerComposeYAML = fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` services: test: image: %s command: "sleep infinity" -`, testutil.AlpineImage) +`, testutil.CommonImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("composeYAML", composePath) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, serviceparser.DefaultContainerName(projectName, "test", "1")) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d") + } + + testCase.Expected = test.Expects(0, nil, nil) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) +} + +func TestComposeUpNoRecreateDependencies(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` +services: + foo: + image: %s + command: "sleep infinity" + bar: + image: %s + command: "sleep infinity" + depends_on: + - foo +`, testutil.CommonImage, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "down").AssertOK() + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + fooContainer := serviceparser.DefaultContainerName(projectName, "foo", "1") + barContainer := serviceparser.DefaultContainerName(projectName, "bar", "1") + + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("projectName", projectName) + data.Labels().Set("fooContainer", fooContainer) + data.Labels().Set("barContainer", barContainer) + } + + testCase.SubTests = []*test.Case{ + { + Description: "foo is not recreated when starting bar", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d", "foo") + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("fooContainer")) + + helpers.Command("inspect", data.Labels().Get("fooContainer"), "--format", "{{.Id}}").Run( + &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + data.Labels().Set("fooContainerID", strings.TrimSpace(stdout)) + }, + }, + ) + + // Bring up dependent service; ensure foo is not recreated (ID unchanged) + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d", "bar") + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("barContainer")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Labels().Get("fooContainer"), "--format", "{{.Id}}") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Equal(t, strings.TrimSpace(stdout), data.Labels().Get("fooContainerID")) + }, + } + }, + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + } + + testCase.Run(t) } func TestComposeUpWithExternalNetwork(t *testing.T) { - containerName1 := testutil.Identifier(t) + "-1" - containerName2 := testutil.Identifier(t) + "-2" - networkName := testutil.Identifier(t) + "-network" - var dockerComposeYaml1 = fmt.Sprintf(` -version: "3" + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var dockerComposeYaml1 = fmt.Sprintf(` services: %s: image: %s @@ -411,9 +797,8 @@ services: networks: %s: external: true -`, containerName1, testutil.NginxAlpineImage, containerName1, networkName, networkName) - var dockerComposeYaml2 = fmt.Sprintf(` -version: "3" +`, data.Identifier("con-1"), testutil.NginxAlpineImage, data.Identifier("con-1"), data.Identifier("network"), data.Identifier("network")) + var dockerComposeYaml2 = fmt.Sprintf(` services: %s: image: %s @@ -425,47 +810,61 @@ services: networks: %s: external: true -`, containerName2, testutil.NginxAlpineImage, containerName2, networkName, networkName) - comp1 := testutil.NewComposeDir(t, dockerComposeYaml1) - defer comp1.CleanUp() - comp2 := testutil.NewComposeDir(t, dockerComposeYaml2) - defer comp2.CleanUp() - base := testutil.NewBase(t) - // Create the test network - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).Run() - // Run the first compose - base.ComposeCmd("-f", comp1.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp1.YAMLFullPath(), "down", "-v").Run() - // Run the second compose - base.ComposeCmd("-f", comp2.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp2.YAMLFullPath(), "down", "-v").Run() - // Down the second compose - base.ComposeCmd("-f", comp2.YAMLFullPath(), "down", "-v").AssertOK() - // Run the second compose again - base.ComposeCmd("-f", comp2.YAMLFullPath(), "up", "-d").AssertOK() - base.Cmd("exec", containerName1, "wget", "-qO-", "http://"+containerName2).AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) +`, data.Identifier("con-2"), testutil.NginxAlpineImage, data.Identifier("con-2"), data.Identifier("network"), data.Identifier("network")) + tmp := data.Temp() + + tmp.Save(dockerComposeYaml1, "project-1", "compose.yaml") + tmp.Save(dockerComposeYaml2, "project-2", "compose.yaml") + + helpers.Ensure("network", "create", data.Identifier("network")) + helpers.Ensure("compose", "-f", tmp.Path("project-1", "compose.yaml"), "up", "-d") + helpers.Ensure("compose", "-f", tmp.Path("project-2", "compose.yaml"), "up", "-d") + helpers.Ensure("compose", "-f", tmp.Path("project-2", "compose.yaml"), "down", "-v") + helpers.Ensure("compose", "-f", tmp.Path("project-2", "compose.yaml"), "up", "-d") + nerdtest.EnsureContainerStarted(helpers, data.Identifier("con-2")) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("exec", data.Identifier("con-1"), "cat", "/etc/hosts") + return helpers.Command("exec", data.Identifier("con-1"), "wget", "-qO-", "http://"+data.Identifier("con-2")) + } + + testCase.Expected = test.Expects(0, nil, expect.Contains(testutil.NginxAlpineIndexHTMLSnippet)) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("project-1", "compose.yaml"), "down", "-v") + helpers.Anyhow("compose", "-f", data.Temp().Path("project-2", "compose.yaml"), "down", "-v") + helpers.Anyhow("network", "rm", data.Identifier("network")) + } + + testCase.Run(t) } func TestComposeUpWithBypass4netns(t *testing.T) { - // docker does not support bypass4netns mode - testutil.DockerIncompatible(t) - if !rootlessutil.IsRootless() { - t.Skip("test needs rootless") - } - testutil.RequireKernelVersion(t, ">= 5.9.0-0") - testutil.RequireSystemService(t, "bypass4netnsd") - base := testutil.NewBase(t) - helpers.ComposeUp(t, base, fmt.Sprintf(` -version: '3.1' + testCase := nerdtest.Setup() -services: + testCase.Require = require.All( + require.Not(nerdtest.Docker), + nerdtest.Rootless, + ) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + testutil.RequireKernelVersion(t, ">= 5.9.0-0") + testutil.RequireSystemService(t, "bypass4netnsd") + + hostPort, err := portlock.Acquire(0) + if err != nil { + helpers.T().Log(fmt.Sprintf("Failed to acquire port: %v", err)) + helpers.T().FailNow() + } + + composeYAML := fmt.Sprintf(` +services: wordpress: image: %s restart: always ports: - - 8080:80 + - %d:80 environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: exampleuser @@ -475,7 +874,6 @@ services: - wordpress:/var/www/html annotations: - nerdctl/bypass4netns=1 - db: image: %s restart: always @@ -492,21 +890,68 @@ services: volumes: wordpress: db: -`, testutil.WordpressImage, testutil.MariaDBImage)) +`, testutil.WordpressImage, hostPort, testutil.MariaDBImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("hostPort", strconv.Itoa(hostPort)) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("projectName", projectName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, serviceparser.DefaultContainerName(projectName, "wordpress", "1")) + nerdtest.EnsureContainerStarted(helpers, serviceparser.DefaultContainerName(projectName, "db", "1")) + + helpers.Command("volume", "inspect", fmt.Sprintf("%s_db", projectName)).Run(&test.Expected{ExitCode: 0}) + helpers.Command("network", "inspect", fmt.Sprintf("%s_default", projectName)).Run(&test.Expected{ExitCode: 0}) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(_ string, tt tig.T) { + host := fmt.Sprintf("http://127.0.0.1:%s", data.Labels().Get("hostPort")) + resp, err := nettestutil.HTTPGet(host, 5, false) + assert.NilError(tt, err) + body, err := io.ReadAll(resp.Body) + assert.NilError(tt, err) + _ = resp.Body.Close() + assert.Assert(tt, strings.Contains(string(body), testutil.WordpressIndexHTMLSnippet)) + t.Log("wordpress seems functional") + }, + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composeYAML") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + } + if p := data.Labels().Get("hostPort"); p != "" { + if port, err := strconv.Atoi(p); err == nil { + _ = portlock.Release(port) + } + } + + if projectName := data.Labels().Get("projectName"); projectName != "" { + helpers.Command("volume", "inspect", fmt.Sprintf("%s_db", projectName)).Run(&test.Expected{ExitCode: 1}) + helpers.Command("network", "inspect", fmt.Sprintf("%s_default", projectName)).Run(&test.Expected{ExitCode: 1}) + } + } + + testCase.Run(t) } func TestComposeUpProfile(t *testing.T) { - base := testutil.NewBase(t) - serviceRegular := testutil.Identifier(t) + "-regular" - serviceProfiled := testutil.Identifier(t) + "-profiled" + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + serviceRegular := data.Identifier("regular") + serviceProfiled := data.Identifier("profiled") - // write the env.common file to tmpdir - tmpDir := t.TempDir() - envFilePath := fmt.Sprintf("%s/env.common", tmpDir) - err := os.WriteFile(envFilePath, []byte("TEST_ENV_INJECTION=WORKS\n"), 0644) - assert.NilError(t, err) + envFilePath := data.Temp().Save(`TEST_ENV_INJECTION=WORKS\n`, "env.common") - dockerComposeYAML := fmt.Sprintf(` + composeYAML := fmt.Sprintf(` services: %s: image: %[3]s @@ -519,119 +964,258 @@ services: - %[4]s `, serviceRegular, serviceProfiled, testutil.NginxAlpineImage, envFilePath) - // * Test with profile - // Should run both the services: - // - matching active profile - // - one without profile - comp1 := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp1.CleanUp() - base.ComposeCmd("-f", comp1.YAMLFullPath(), "--profile", "test-profile", "up", "-d").AssertOK() - - psCmd := base.Cmd("ps", "-a", "--format={{.Names}}") - psCmd.AssertOutContains(serviceRegular) - psCmd.AssertOutContains(serviceProfiled) - - execCmd := base.ComposeCmd("-f", comp1.YAMLFullPath(), "exec", serviceProfiled, "env") - execCmd.AssertOutContains("TEST_ENV_INJECTION=WORKS") - - base.ComposeCmd("-f", comp1.YAMLFullPath(), "--profile", "test-profile", "down", "-v").AssertOK() - - // * Test without profile - // Should run: - // - service without profile - comp2 := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp2.CleanUp() - base.ComposeCmd("-f", comp2.YAMLFullPath(), "up", "-d").AssertOK() - defer base.ComposeCmd("-f", comp2.YAMLFullPath(), "down", "-v").AssertOK() - - psCmd = base.Cmd("ps", "-a", "--format={{.Names}}") - psCmd.AssertOutContains(serviceRegular) - psCmd.AssertOutNotContains(serviceProfiled) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("serviceRegular", serviceRegular) + data.Labels().Set("serviceProfiled", serviceProfiled) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("regularContainer", serviceparser.DefaultContainerName(projectName, serviceRegular, "1")) + data.Labels().Set("profiledContainer", serviceparser.DefaultContainerName(projectName, serviceProfiled, "1")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "with profile", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "--profile", "test-profile", "up", "-d") + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("regularContainer")) + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("profiledContainer")) + + helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "exec", data.Labels().Get("serviceProfiled"), "env"). + Run(&test.Expected{ + ExitCode: 0, + Output: expect.Contains("TEST_ENV_INJECTION=WORKS"), + }) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("ps", "-a", "--format={{.Names}}") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.Contains(data.Labels().Get("serviceRegular")), + expect.Contains(data.Labels().Get("serviceProfiled")), + ), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "--profile", "test-profile", "down", "-v") + }, + }, + { + Description: "profiled not started without profile flag", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d") + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("regularContainer")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("ps", "-a", "--format={{.Names}}") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.Contains(data.Labels().Get("serviceRegular")), + expect.DoesNotContain(data.Labels().Get("serviceProfiled")), + ), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + }, + }, + } + + testCase.Run(t) } func TestComposeUpAbortOnContainerExit(t *testing.T) { - base := testutil.NewBase(t) - serviceRegular := "regular" - serviceProfiled := "exited" - dockerComposeYAML := fmt.Sprintf(` + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + serviceRegular := data.Identifier("regular") + serviceProfiled := data.Identifier("exited") + composeYAML := fmt.Sprintf(` services: %s: image: %s - ports: - - 8080:80 %s: image: %s entrypoint: /bin/sh -c "exit 1" `, serviceRegular, testutil.NginxAlpineImage, serviceProfiled, testutil.BusyboxImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - - // here we run 'compose up --abort-on-container-exit' command - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "--abort-on-container-exit").AssertExitCode(1) - time.Sleep(3 * time.Second) - psCmd := base.Cmd("ps", "-a", "--format={{.Names}}", "--filter", "status=exited") - - psCmd.AssertOutContains(serviceRegular) - psCmd.AssertOutContains(serviceProfiled) - base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() - - // this time we run 'compose up' command without --abort-on-container-exit flag - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() - time.Sleep(3 * time.Second) - psCmd = base.Cmd("ps", "-a", "--format={{.Names}}", "--filter", "status=exited") - - // this time the regular service should not be listed in the output - psCmd.AssertOutNotContains(serviceRegular) - psCmd.AssertOutContains(serviceProfiled) - base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() - - // in this sub-test we are ensuring that flags '-d' and '--abort-on-container-exit' cannot be ran together - c := base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d", "--abort-on-container-exit") - expected := icmd.Expected{ - ExitCode: 1, - } - c.Assert(expected) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + projectName := filepath.Base(filepath.Dir(composePath)) + t.Logf("projectName=%q", projectName) + + data.Labels().Set("serviceRegular", serviceRegular) + data.Labels().Set("serviceProfiled", serviceProfiled) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("regularContainer", serviceparser.DefaultContainerName(projectName, serviceRegular, "1")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "abort on container exit", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "--abort-on-container-exit").Run( + &test.Expected{ + ExitCode: 1, + }, + ) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("ps", "-a", "--format={{.Names}}", "--filter", "status=exited") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.Contains(data.Labels().Get("serviceRegular")), + expect.Contains(data.Labels().Get("serviceProfiled")), + ), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + }, + }, + { + Description: "no abort flag keeps other services running", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d") + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("regularContainer")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("ps", "-a", "--format={{.Names}}", "--filter", "status=exited") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + expect.DoesNotContain(data.Labels().Get("serviceRegular")), + expect.Contains(data.Labels().Get("serviceProfiled")), + ), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v") + }, + }, + // in this sub-test we are ensuring that flags '-d' and '--abort-on-container-exit' cannot be ran together + { + Description: "flag -d incompatible with --abort-on-container-exit", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "-d", "--abort-on-container-exit") + }, + Expected: test.Expects(1, nil, nil), + }, + } + + testCase.Run(t) } func TestComposeUpPull(t *testing.T) { - base := testutil.NewBase(t) + testCase := nerdtest.Setup() + + testCase.NoParallel = true + testCase.Require = nerdtest.Private - var dockerComposeYAML = fmt.Sprintf(` + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` services: test: image: %s command: sh -euxc "echo hi" `, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - - // Cases where pull is required - for _, pull := range []string{"missing", "always"} { - t.Run(fmt.Sprintf("pull=%s", pull), func(t *testing.T) { - base.Cmd("rmi", "-f", testutil.CommonImage).Run() - base.Cmd("images").AssertOutNotContains(testutil.CommonImage) - t.Cleanup(func() { - base.ComposeCmd("-f", comp.YAMLFullPath(), "down").AssertOK() - }) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "--pull", pull).AssertOutContains("hi") - }) - } - - t.Run("pull=never, no pull", func(t *testing.T) { - base.Cmd("rmi", "-f", testutil.CommonImage).Run() - base.Cmd("images").AssertOutNotContains(testutil.CommonImage) - t.Cleanup(func() { - base.ComposeCmd("-f", comp.YAMLFullPath(), "down").AssertOK() - }) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "--pull", "never").AssertExitCode(1) - }) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + data.Labels().Set("composeYAML", composePath) + } + + testCase.SubTests = []*test.Case{ + { + Description: "pull=missing", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Command("images").Run( + &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(testutil.CommonImage), + }, + ) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "--pull", "missing") + }, + Expected: test.Expects(0, nil, expect.Contains("hi")), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down") + }, + }, + { + Description: "pull=always", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Command("images").Run( + &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(testutil.CommonImage), + }, + ) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "--pull", "always") + }, + Expected: test.Expects(0, nil, expect.Contains("hi")), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down") + }, + }, + { + Description: "pull=never, no pull", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Command("images").Run( + &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(testutil.CommonImage), + }, + ) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up", "--pull", "never") + }, + Expected: test.Expects(1, nil, nil), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down") + }, + }, + } + + testCase.Run(t) } func TestComposeUpServicePullPolicy(t *testing.T) { - base := testutil.NewBase(t) + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.Private - var dockerComposeYAML = fmt.Sprintf(` + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var composeYAML = fmt.Sprintf(` services: test: image: %s @@ -639,10 +1223,94 @@ services: pull_policy: "never" `, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + data.Labels().Set("composeYAML", composePath) + + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Command("images").Run( + &test.Expected{ + ExitCode: 0, + Output: expect.DoesNotContain(testutil.CommonImage), + }, + ) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "up") + } + + testCase.Expected = test.Expects(1, nil, nil) + + testCase.Run(t) +} + +func TestComposeTmpfsVolume(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + containerName := data.Identifier("tmpfs") + composeYAML := fmt.Sprintf(` +services: + tmpfs: + container_name: %s + image: %s + command: sleep infinity + volumes: + - type: tmpfs + target: /target-rw + tmpfs: + size: 64m + - type: tmpfs + target: /target-ro + read_only: true + tmpfs: + size: 64m + mode: 0o1770 +`, containerName, testutil.CommonImage) + + composeYAMLPath := data.Temp().Save(composeYAML, "compose.yaml") + + helpers.Ensure("compose", "-f", composeYAMLPath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, containerName) + + data.Labels().Set("composeYAML", composeYAMLPath) + data.Labels().Set("containerName", containerName) + } + + testCase.SubTests = []*test.Case{ + { + Description: "rw tmpfs mount", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("containerName"), "grep", "/target-rw", "/proc/mounts") + }, + Expected: test.Expects(0, nil, + expect.All( + expect.Contains("/target-rw"), + expect.Contains("rw"), + expect.Contains("size=65536k"), + ), + ), + }, + { + Description: "ro tmpfs mount with mode", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("containerName"), "grep", "/target-ro", "/proc/mounts") + }, + Expected: test.Expects(0, nil, + expect.All( + expect.Contains("/target-ro"), + expect.Contains("ro"), + expect.Contains("size=65536k"), + expect.Contains("mode=1770"), + ), + ), + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down") + } - base.Cmd("rmi", "-f", testutil.CommonImage).Run() - base.Cmd("images").AssertOutNotContains(testutil.CommonImage) - base.ComposeCmd("-f", comp.YAMLFullPath(), "up").AssertExitCode(1) + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_up_test.go b/cmd/nerdctl/compose/compose_up_test.go index e162ee6806d..8821d19f6d2 100644 --- a/cmd/nerdctl/compose/compose_up_test.go +++ b/cmd/nerdctl/compose/compose_up_test.go @@ -17,14 +17,15 @@ package compose import ( + "errors" "fmt" - "os" - "path/filepath" - "runtime" "testing" "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -32,56 +33,73 @@ import ( // https://github.com/containerd/nerdctl/issues/1942 func TestComposeUpDetailedError(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("FIXME: test does not work on Windows yet (runtime \"io.containerd.runc.v2\" binary not installed \"containerd-shim-runc-v2.exe\": file does not exist)") - } - base := testutil.NewBase(t) dockerComposeYAML := fmt.Sprintf(` services: foo: image: %s runtime: invalid `, testutil.CommonImage) - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - c := base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d") - expected := icmd.Expected{ - ExitCode: 1, - Err: `exec: \"invalid\": executable file not found in $PATH`, + testCase := nerdtest.Setup() + + // "FIXME: test does not work on Windows yet (runtime \"io.containerd.runc.v2\" binary not installed \"containerd-shim-runc-v2.exe\": file does not exist) + testCase.Require = require.Not(require.Windows) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerComposeYAML, "compose.yaml") } - // Docker expected err is different - if nerdtest.IsDocker() { - expected.Err = `unknown or invalid runtime name: invalid` + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Temp().Path("compose.yaml"), "up", "-d") } - c.Assert(expected) + + testCase.Expected = test.Expects( + 1, + []error{errors.New(`invalid runtime name`)}, + nil, + ) + + testCase.Run(t) } // https://github.com/containerd/nerdctl/issues/1652 func TestComposeUpBindCreateHostPath(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip(`FIXME: no support for Windows path: (error: "volume target must be an absolute path, got \"/mnt\")`) - } + testCase := nerdtest.Setup() - base := testutil.NewBase(t) + // `FIXME: no support for Windows path: (error: "volume target must be an absolute path, got \"/mnt\")` + testCase.Require = require.Not(require.Windows) - var dockerComposeYAML = fmt.Sprintf(` + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var dockerComposeYAML = fmt.Sprintf(` services: test: image: %s command: sh -euxc "echo hi >/mnt/test" volumes: - # ./foo should be automatically created - - ./foo:/mnt -`, testutil.CommonImage) + # tempdir/foo should be automatically created + - %s:/mnt +`, testutil.CommonImage, data.Temp().Path("foo")) + + data.Temp().Save(dockerComposeYAML, "compose.yaml") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", data.Temp().Path("compose.yaml"), "up") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down") + } - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: nil, + Output: func(stdout string, t tig.T) { + assert.Equal(t, data.Temp().Load("foo", "test"), "hi\n") + }, + } + } - base.ComposeCmd("-f", comp.YAMLFullPath(), "up").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down").AssertOK() - testFile := filepath.Join(comp.Dir(), "foo", "test") - testB, err := os.ReadFile(testFile) - assert.NilError(t, err) - assert.Equal(t, "hi\n", string(testB)) + testCase.Run(t) } diff --git a/cmd/nerdctl/compose/compose_version_test.go b/cmd/nerdctl/compose/compose_version_test.go index af3028b3d65..04cdd244052 100644 --- a/cmd/nerdctl/compose/compose_version_test.go +++ b/cmd/nerdctl/compose/compose_version_test.go @@ -19,20 +19,29 @@ package compose import ( "testing" - "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestComposeVersion(t *testing.T) { - base := testutil.NewBase(t) - base.ComposeCmd("version").AssertOutContains("Compose version ") + testCase := nerdtest.Setup() + testCase.Command = test.Command("compose", "version") + testCase.Expected = test.Expects(0, nil, expect.Contains("Compose version ")) + testCase.Run(t) } func TestComposeVersionShort(t *testing.T) { - base := testutil.NewBase(t) - base.ComposeCmd("version", "--short").AssertOK() + testCase := nerdtest.Setup() + testCase.Command = test.Command("compose", "version", "--short") + testCase.Expected = test.Expects(0, nil, nil) + testCase.Run(t) } func TestComposeVersionJson(t *testing.T) { - base := testutil.NewBase(t) - base.ComposeCmd("version", "--format", "json").AssertOutContains("{\"version\":\"") + testCase := nerdtest.Setup() + testCase.Command = test.Command("compose", "version", "--format", "json") + testCase.Expected = test.Expects(0, nil, expect.Contains("{\"version\":\"")) + testCase.Run(t) } diff --git a/cmd/nerdctl/container/container.go b/cmd/nerdctl/container/container.go index 2d92f8d5922..1696874be01 100644 --- a/cmd/nerdctl/container/container.go +++ b/cmd/nerdctl/container/container.go @@ -54,6 +54,8 @@ func Command() *cobra.Command { pruneCommand(), StatsCommand(), AttachCommand(), + HealthCheckCommand(), + ExportCommand(), ) AddCpCommand(cmd) return cmd diff --git a/cmd/nerdctl/container/container_attach.go b/cmd/nerdctl/container/container_attach.go index 958c7c4b7ec..5fd004ae36e 100644 --- a/cmd/nerdctl/container/container_attach.go +++ b/cmd/nerdctl/container/container_attach.go @@ -17,6 +17,8 @@ package container import ( + "io" + "github.com/spf13/cobra" containerd "github.com/containerd/containerd/v2/client" @@ -56,6 +58,7 @@ Caveats: SilenceErrors: true, } cmd.Flags().String("detach-keys", consoleutil.DefaultDetachKeys, "Override the default detach keys") + cmd.Flags().Bool("no-stdin", false, "Do not attach STDIN") return cmd } @@ -68,9 +71,18 @@ func attachOptions(cmd *cobra.Command) (types.ContainerAttachOptions, error) { if err != nil { return types.ContainerAttachOptions{}, err } + noStdin, err := cmd.Flags().GetBool("no-stdin") + if err != nil { + return types.ContainerAttachOptions{}, err + } + + var stdin io.Reader + if !noStdin { + stdin = cmd.InOrStdin() + } return types.ContainerAttachOptions{ GOptions: globalOptions, - Stdin: cmd.InOrStdin(), + Stdin: stdin, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), DetachKeys: detachKeys, diff --git a/cmd/nerdctl/container/container_attach_linux_test.go b/cmd/nerdctl/container/container_attach_linux_test.go index 083fd4a194e..ee265480c2e 100644 --- a/cmd/nerdctl/container/container_attach_linux_test.go +++ b/cmd/nerdctl/container/container_attach_linux_test.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -64,7 +65,7 @@ func TestAttach(t *testing.T) { cmd.Run(&test.Expected{ ExitCode: 0, Errors: []error{errors.New("read detach keys")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, }) @@ -93,7 +94,7 @@ func TestAttach(t *testing.T) { Errors: []error{errors.New("read detach keys")}, Output: expect.All( expect.Contains("markmark"), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, ), @@ -125,7 +126,7 @@ func TestAttachDetachKeys(t *testing.T) { cmd.Run(&test.Expected{ ExitCode: 0, Errors: []error{errors.New("read detach keys")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, }) @@ -153,7 +154,7 @@ func TestAttachDetachKeys(t *testing.T) { Errors: []error{errors.New("read detach keys")}, Output: expect.All( expect.Contains("markmark"), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, ), @@ -182,8 +183,8 @@ func TestAttachForAutoRemovedContainer(t *testing.T) { cmd.Run(&test.Expected{ ExitCode: 0, Errors: []error{errors.New("read detach keys")}, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true"), info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, }) } @@ -202,7 +203,7 @@ func TestAttachForAutoRemovedContainer(t *testing.T) { ExitCode: 42, Output: expect.All( expect.Contains("markmark"), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, !strings.Contains(helpers.Capture("ps", "-a"), data.Identifier())) }, ), @@ -211,3 +212,44 @@ func TestAttachForAutoRemovedContainer(t *testing.T) { testCase.Run(t) } + +func TestAttachNoStdin(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("run", "-it", "--detach-keys=ctrl-p,ctrl-q", "--name", data.Identifier(), + testutil.CommonImage, "sleep", "5") + cmd.WithPseudoTTY() + cmd.Feed(bytes.NewReader([]byte{16, 17})) // Ctrl-p, Ctrl-q to detach (https://en.wikipedia.org/wiki/C0_and_C1_control_codes) + cmd.Run(&test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "{{.State.Running}}", data.Identifier()), "true")) + }, + }) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("attach", "--no-stdin", data.Identifier()) + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("should-not-appear\n")) + cmd.Feed(bytes.NewReader([]byte{16, 17})) + return cmd + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, // Since it's a normal exit and not detach. + Output: func(stdout string, t tig.T) { + logs := helpers.Capture("logs", data.Identifier()) + assert.Assert(t, !strings.Contains(logs, "should-not-appear")) + }, + } + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_commit.go b/cmd/nerdctl/container/container_commit.go index 7db58bca88e..14db2be2a0b 100644 --- a/cmd/nerdctl/container/container_commit.go +++ b/cmd/nerdctl/container/container_commit.go @@ -17,6 +17,8 @@ package container import ( + "errors" + "github.com/spf13/cobra" "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" @@ -40,6 +42,15 @@ func CommitCommand() *cobra.Command { cmd.Flags().StringP("message", "m", "", "Commit message") cmd.Flags().StringArrayP("change", "c", nil, "Apply Dockerfile instruction to the created image (supported directives: [CMD, ENTRYPOINT])") cmd.Flags().BoolP("pause", "p", true, "Pause container during commit") + cmd.Flags().StringP("compression", "", "gzip", "commit compression algorithm (zstd or gzip)") + cmd.Flags().String("format", "docker", "Format of the committed image (docker or oci)") + cmd.Flags().Bool("estargz", false, "Convert the committed layer to eStargz for lazy pulling") + cmd.Flags().Int("estargz-compression-level", 9, "eStargz compression level (1-9)") + cmd.Flags().Int("estargz-chunk-size", 0, "eStargz chunk size") + cmd.Flags().Int("estargz-min-chunk-size", 0, "The minimal number of bytes of data must be written in one gzip stream") + cmd.Flags().Bool("zstdchunked", false, "Convert the committed layer to zstd:chunked for lazy pulling") + cmd.Flags().Int("zstdchunked-compression-level", 3, "zstd:chunked compression level") + cmd.Flags().Int("zstdchunked-chunk-size", 0, "zstd:chunked chunk size") return cmd } @@ -66,15 +77,78 @@ func commitOptions(cmd *cobra.Command) (types.ContainerCommitOptions, error) { return types.ContainerCommitOptions{}, err } + com, err := cmd.Flags().GetString("compression") + if err != nil { + return types.ContainerCommitOptions{}, err + } + if com != string(types.Zstd) && com != string(types.Gzip) { + return types.ContainerCommitOptions{}, errors.New("--compression param only supports zstd or gzip") + } + + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.ContainerCommitOptions{}, err + } + if format != string(types.ImageFormatDocker) && format != string(types.ImageFormatOCI) { + return types.ContainerCommitOptions{}, errors.New("--format param only supports docker or oci") + } + + estargz, err := cmd.Flags().GetBool("estargz") + if err != nil { + return types.ContainerCommitOptions{}, err + } + estargzCompressionLevel, err := cmd.Flags().GetInt("estargz-compression-level") + if err != nil { + return types.ContainerCommitOptions{}, err + } + estargzChunkSize, err := cmd.Flags().GetInt("estargz-chunk-size") + if err != nil { + return types.ContainerCommitOptions{}, err + } + estargzMinChunkSize, err := cmd.Flags().GetInt("estargz-min-chunk-size") + if err != nil { + return types.ContainerCommitOptions{}, err + } + + zstdchunked, err := cmd.Flags().GetBool("zstdchunked") + if err != nil { + return types.ContainerCommitOptions{}, err + } + zstdchunkedCompressionLevel, err := cmd.Flags().GetInt("zstdchunked-compression-level") + if err != nil { + return types.ContainerCommitOptions{}, err + } + zstdchunkedChunkSize, err := cmd.Flags().GetInt("zstdchunked-chunk-size") + if err != nil { + return types.ContainerCommitOptions{}, err + } + + // estargz and zstdchunked are mutually exclusive + if estargz && zstdchunked { + return types.ContainerCommitOptions{}, errors.New("options --estargz and --zstdchunked lead to conflict, only one of them can be used") + } + return types.ContainerCommitOptions{ - Stdout: cmd.OutOrStdout(), - GOptions: globalOptions, - Author: author, - Message: message, - Pause: pause, - Change: change, + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Author: author, + Message: message, + Pause: pause, + Change: change, + Compression: types.CompressionType(com), + Format: types.ImageFormat(format), + EstargzOptions: types.EstargzOptions{ + Estargz: estargz, + EstargzCompressionLevel: estargzCompressionLevel, + EstargzChunkSize: estargzChunkSize, + EstargzMinChunkSize: estargzMinChunkSize, + }, + ZstdChunkedOptions: types.ZstdChunkedOptions{ + ZstdChunked: zstdchunked, + ZstdChunkedCompressionLevel: zstdchunkedCompressionLevel, + ZstdChunkedChunkSize: zstdchunkedChunkSize, + }, }, nil - } func commitAction(cmd *cobra.Command, args []string) error { @@ -82,7 +156,6 @@ func commitAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address) if err != nil { return err diff --git a/cmd/nerdctl/container/container_commit_linux_test.go b/cmd/nerdctl/container/container_commit_linux_test.go index e1a167c0633..3bd5b98cf42 100644 --- a/cmd/nerdctl/container/container_commit_linux_test.go +++ b/cmd/nerdctl/container/container_commit_linux_test.go @@ -19,8 +19,10 @@ package container import ( "strings" "testing" + "time" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -41,7 +43,7 @@ func TestKubeCommitSave(t *testing.T) { nerdtest.KubeCtlCommand(helpers, "wait", "pod", identifier, "--for=condition=ready", "--timeout=1m").Run(&test.Expected{}) nerdtest.KubeCtlCommand(helpers, "exec", identifier, "--", "mkdir", "-p", "/tmp/whatever").Run(&test.Expected{}) nerdtest.KubeCtlCommand(helpers, "get", "pods", identifier, "-o", "jsonpath={ .status.containerStatuses[0].containerID }").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { containerID = strings.TrimPrefix(stdout, "containerd://") }, }) @@ -53,8 +55,22 @@ func TestKubeCommitSave(t *testing.T) { } testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { - helpers.Ensure("commit", data.Labels().Get("containerID"), "testcommitsave") - return helpers.Command("save", "testcommitsave") + helpers.Ensure("commit", data.Labels().Get("containerID"), data.Identifier("testcommitsave")) + // Wait for the image to show up + for range 5 { + found := false + cmd := helpers.Command("images", data.Identifier("testcommitsave"), "--format", "json") + cmd.Run(&test.Expected{ + Output: func(stdout string, t tig.T) { + found = strings.TrimSpace(stdout) != "" + }, + }) + if found { + break + } + time.Sleep(1 * time.Second) + } + return helpers.Command("save", data.Identifier("testcommitsave")) } testCase.Expected = test.Expects(0, nil, nil) @@ -73,7 +89,7 @@ func TestKubeCommitSave(t *testing.T) { cmd = nerdtest.KubeCtlCommand(helpers, "get", "pods", tID, "-o", "jsonpath={ .status.hostIPs[0].ip }") cmd.Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { registryIP = stdout }, }) diff --git a/cmd/nerdctl/container/container_commit_test.go b/cmd/nerdctl/container/container_commit_test.go index b0744f014a8..68291c39714 100644 --- a/cmd/nerdctl/container/container_commit_test.go +++ b/cmd/nerdctl/container/container_commit_test.go @@ -19,10 +19,14 @@ package container import ( "testing" + "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -86,3 +90,52 @@ func TestCommit(t *testing.T) { testCase.Run(t) } + +func TestZstdCommit(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.All( + // FIXME: Docker does not support compression + require.Not(nerdtest.Docker), + nerdtest.ContainerdVersion("2.0.0"), + nerdtest.CGroup, + ) + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Identifier("image")) + } + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, identifier) + helpers.Ensure("exec", identifier, "sh", "-euxc", `echo hello-test-commit > /foo`) + helpers.Ensure("commit", identifier, data.Identifier("image"), "--compression=zstd") + data.Labels().Set("image", data.Identifier("image")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "verify zstd has been used", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "inspect", "--mode=native", data.Labels().Get("image")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.JSON([]native.Image{}, func(images []native.Image, t tig.T) { + assert.Equal(t, len(images), 1) + assert.Equal(helpers.T(), images[0].Manifest.Layers[len(images[0].Manifest.Layers)-1].MediaType, "application/vnd.docker.image.rootfs.diff.tar.zstd") + }), + } + }, + }, + { + Description: "verify the image is working", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Labels().Get("image"), "sh", "-c", "--", "cat /foo") + }, + Expected: test.Expects(0, nil, expect.Equals("hello-test-commit\n")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_create.go b/cmd/nerdctl/container/container_create.go index e8d7e6a4d33..e30d529481a 100644 --- a/cmd/nerdctl/container/container_create.go +++ b/cmd/nerdctl/container/container_create.go @@ -258,6 +258,36 @@ func createOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) { } // #endregion + // #region for healthcheck flags + opt.HealthCmd, err = cmd.Flags().GetString("health-cmd") + if err != nil { + return opt, err + } + opt.HealthInterval, err = cmd.Flags().GetDuration("health-interval") + if err != nil { + return opt, err + } + opt.HealthTimeout, err = cmd.Flags().GetDuration("health-timeout") + if err != nil { + return opt, err + } + opt.HealthRetries, err = cmd.Flags().GetInt("health-retries") + if err != nil { + return opt, err + } + opt.HealthStartPeriod, err = cmd.Flags().GetDuration("health-start-period") + if err != nil { + return opt, err + } + opt.NoHealthcheck, err = cmd.Flags().GetBool("no-healthcheck") + if err != nil { + return opt, err + } + if err := helpers.ValidateHealthcheckFlags(opt); err != nil { + return opt, err + } + // #endregion + // #region for intel RDT flags opt.RDTClass, err = cmd.Flags().GetString("rdt-class") if err != nil { @@ -371,7 +401,6 @@ func createOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) { // #endregion // #region for metadata flags - opt.NameChanged = cmd.Flags().Changed("name") opt.Name, err = cmd.Flags().GetString("name") if err != nil { return opt, err @@ -506,7 +535,7 @@ func createAction(cmd *cobra.Command, args []string) error { } defer cancel() - netFlags, err := loadNetworkFlags(cmd) + netFlags, err := loadNetworkFlags(cmd, createOpt.GOptions) if err != nil { return fmt.Errorf("failed to load networking flags: %w", err) } diff --git a/cmd/nerdctl/container/container_create_linux_test.go b/cmd/nerdctl/container/container_create_linux_test.go index 66da8a19e86..1551cdf3555 100644 --- a/cmd/nerdctl/container/container_create_linux_test.go +++ b/cmd/nerdctl/container/container_create_linux_test.go @@ -34,6 +34,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -235,7 +236,7 @@ func TestIssue2993(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("is already used by ID")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { containersDirs, err := os.ReadDir(data.Labels().Get(containersPathKey)) assert.NilError(t, err) assert.Equal(t, len(containersDirs), 1) @@ -282,7 +283,7 @@ func TestIssue2993(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { containersDirs, err := os.ReadDir(data.Labels().Get(containersPathKey)) assert.NilError(t, err) assert.Equal(t, len(containersDirs), 0) @@ -363,10 +364,10 @@ func TestUsernsMappingCreateCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) assert.NilError(t, err, "Failed to get container host UID") - assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID")) }, } }, diff --git a/cmd/nerdctl/container/container_create_test.go b/cmd/nerdctl/container/container_create_test.go index 07a14a3136c..394a90ed4d0 100644 --- a/cmd/nerdctl/container/container_create_test.go +++ b/cmd/nerdctl/container/container_create_test.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -96,13 +97,13 @@ func TestCreateHyperVContainer(t *testing.T) { helpers.Command("container", "inspect", data.Labels().Get("cID")). Run(&test.Expected{ ExitCode: expect.ExitCodeNoCheck, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Container err := json.Unmarshal([]byte(stdout), &dc) if err != nil || len(dc) == 0 { return } - assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n"+info) + assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n") ran = dc[0].State.Status == "exited" }, }) diff --git a/cmd/nerdctl/container/container_diff_test.go b/cmd/nerdctl/container/container_diff_test.go index dc09244a3a0..b2ab02191ab 100644 --- a/cmd/nerdctl/container/container_diff_test.go +++ b/cmd/nerdctl/container/container_diff_test.go @@ -39,7 +39,7 @@ func TestDiff(t *testing.T) { testCase.Require = require.Not(require.Windows) testCase.Setup = func(data test.Data, helpers test.Helpers) { - helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, "sh", "-euxc", "touch /a; touch /bin/b; rm /bin/base64") } diff --git a/cmd/nerdctl/container/container_exec.go b/cmd/nerdctl/container/container_exec.go index e9684a5435e..b39d04eacdb 100644 --- a/cmd/nerdctl/container/container_exec.go +++ b/cmd/nerdctl/container/container_exec.go @@ -62,27 +62,27 @@ func execOptions(cmd *cobra.Command) (types.ContainerExecOptions, error) { return types.ContainerExecOptions{}, err } - flagI, err := cmd.Flags().GetBool("interactive") + isInteractive, err := cmd.Flags().GetBool("interactive") if err != nil { return types.ContainerExecOptions{}, err } - flagT, err := cmd.Flags().GetBool("tty") + isTerminal, err := cmd.Flags().GetBool("tty") if err != nil { return types.ContainerExecOptions{}, err } - flagD, err := cmd.Flags().GetBool("detach") + isDetach, err := cmd.Flags().GetBool("detach") if err != nil { return types.ContainerExecOptions{}, err } - if flagI { - if flagD { + if isInteractive { + if isDetach { return types.ContainerExecOptions{}, errors.New("currently flag -i and -d cannot be specified together (FIXME)") } } - if flagT { - if flagD { + if isTerminal { + if isDetach { return types.ContainerExecOptions{}, errors.New("currently flag -t and -d cannot be specified together (FIXME)") } } @@ -111,9 +111,9 @@ func execOptions(cmd *cobra.Command) (types.ContainerExecOptions, error) { return types.ContainerExecOptions{ GOptions: globalOptions, - TTY: flagT, - Interactive: flagI, - Detach: flagD, + TTY: isTerminal, + Interactive: isInteractive, + Detach: isDetach, Workdir: workdir, Env: env, EnvFile: envFile, diff --git a/cmd/nerdctl/container/container_exec_linux_test.go b/cmd/nerdctl/container/container_exec_linux_test.go index 5ff812d9429..9eafe7939bd 100644 --- a/cmd/nerdctl/container/container_exec_linux_test.go +++ b/cmd/nerdctl/container/container_exec_linux_test.go @@ -65,6 +65,9 @@ func TestExecTTY(t *testing.T) { testCase.Setup = func(data test.Data, helpers test.Helpers) { helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + data.Labels().Set("container_name", data.Identifier()) } diff --git a/cmd/nerdctl/container/container_exec_test.go b/cmd/nerdctl/container/container_exec_test.go index f5a15e3572a..d1a14e9d410 100644 --- a/cmd/nerdctl/container/container_exec_test.go +++ b/cmd/nerdctl/container/container_exec_test.go @@ -17,107 +17,124 @@ package container import ( - "errors" "runtime" "strings" "testing" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestExec(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - defer base.Cmd("rm", "-f", testContainer).Run() - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - base.EnsureContainerStarted(testContainer) - - base.Cmd("exec", testContainer, "echo", "success").AssertOutExactly("success\n") + nerdtest.Setup() + + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Identifier(), "echo", "success") + }, + Expected: test.Expects(0, nil, expect.Equals("success\n")), + } + testCase.Run(t) } func TestExecWithDoubleDash(t *testing.T) { - t.Parallel() - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - defer base.Cmd("rm", "-f", testContainer).Run() - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - base.EnsureContainerStarted(testContainer) - - base.Cmd("exec", testContainer, "--", "echo", "success").AssertOutExactly("success\n") + nerdtest.Setup() + + testCase := &test.Case{ + Require: require.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Identifier(), "--", "echo", "success") + }, + Expected: test.Expects(0, nil, expect.Equals("success\n")), + } + testCase.Run(t) } func TestExecStdin(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - - testContainer := testutil.Identifier(t) - defer base.Cmd("rm", "-f", testContainer).Run() - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - base.EnsureContainerStarted(testContainer) + nerdtest.Setup() const testStr = "test-exec-stdin" - opts := []func(*testutil.Cmd){ - testutil.WithStdin(strings.NewReader(testStr)), + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("exec", "-i", data.Identifier(), "cat") + cmd.Feed(strings.NewReader(testStr)) + return cmd + }, + Expected: test.Expects(0, nil, expect.Equals(testStr)), } - base.Cmd("exec", "-i", testContainer, "cat").CmdOption(opts...).AssertOutExactly(testStr) + testCase.Run(t) } // FYI: https://github.com/containerd/nerdctl/blob/e4b2b6da56555dc29ed66d0fd8e7094ff2bc002d/cmd/nerdctl/run_test.go#L177 func TestExecEnv(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - defer base.Cmd("rm", "-f", testContainer).Run() - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - base.EnsureContainerStarted(testContainer) - - base.Env = append(base.Env, "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") - base.Cmd("exec", - "--env", "FOO=foo1,foo2", - "--env", "BAR=bar1 bar2", - "--env", "BAZ=", - "--env", "QUX", // not exported in OS - "--env", "QUUX=quux1", - "--env", "QUUX=quux2", - "--env", "CORGE", // OS exported - "--env", "GRAULT=grault_key=grault_value", // value contains `=` char - "--env", "GARPLY=", // OS exported - "--env", "WALDO=", // not exported in OS - - testContainer, "env").AssertOutWithFunc(func(stdout string) error { - if !strings.Contains(stdout, "\nFOO=foo1,foo2\n") { - return errors.New("got bad FOO") - } - if !strings.Contains(stdout, "\nBAR=bar1 bar2\n") { - return errors.New("got bad BAR") - } - if !strings.Contains(stdout, "\nBAZ=\n") && runtime.GOOS != "windows" { - return errors.New("got bad BAZ") - } - if strings.Contains(stdout, "QUX") { - return errors.New("got bad QUX (should not be set)") - } - if !strings.Contains(stdout, "\nQUUX=quux2\n") { - return errors.New("got bad QUUX") - } - if !strings.Contains(stdout, "\nCORGE=corge-value-in-host\n") { - return errors.New("got bad CORGE") - } - if !strings.Contains(stdout, "\nGRAULT=grault_key=grault_value\n") { - return errors.New("got bad GRAULT") - } - if !strings.Contains(stdout, "\nGARPLY=\n") && runtime.GOOS != "windows" { - return errors.New("got bad GARPLY") - } - if !strings.Contains(stdout, "\nWALDO=\n") && runtime.GOOS != "windows" { - return errors.New("got bad WALDO") - } - - return nil - }) + nerdtest.Setup() + + testCase := &test.Case{ + Env: map[string]string{ + "CORGE": "corge-value-in-host", + "GARPLY": "garply-value-in-host", + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", + "--env", "FOO=foo1,foo2", + "--env", "BAR=bar1 bar2", + "--env", "BAZ=", + "--env", "QUX", // not exported in OS + "--env", "QUUX=quux1", + "--env", "QUUX=quux2", + "--env", "CORGE", // OS exported + "--env", "GRAULT=grault_key=grault_value", // value contains `=` char + "--env", "GARPLY=", // OS exported + "--env", "WALDO=", // not exported in OS + + data.Identifier(), "env") + }, + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, "\nFOO=foo1,foo2\n"), "got bad FOO") + assert.Assert(t, strings.Contains(stdout, "\nBAR=bar1 bar2\n"), "got bad BAR") + if runtime.GOOS != "windows" { + assert.Assert(t, strings.Contains(stdout, "\nBAZ=\n"), "got bad BAZ") + } + assert.Assert(t, !strings.Contains(stdout, "QUX"), "got bad QUX (should not be set)") + assert.Assert(t, strings.Contains(stdout, "\nQUUX=quux2\n"), "got bad QUUX") + assert.Assert(t, strings.Contains(stdout, "\nCORGE=corge-value-in-host\n"), "got bad CORGE") + assert.Assert(t, strings.Contains(stdout, "\nGRAULT=grault_key=grault_value\n"), "got bad GRAULT") + if runtime.GOOS != "windows" { + assert.Assert(t, strings.Contains(stdout, "\nGARPLY=\n"), "got bad GARPLY") + assert.Assert(t, strings.Contains(stdout, "\nWALDO=\n"), "got bad WALDO") + } + }), + } + testCase.Run(t) } diff --git a/cmd/nerdctl/container/container_export.go b/cmd/nerdctl/container/container_export.go new file mode 100644 index 00000000000..d7fc7abc580 --- /dev/null +++ b/cmd/nerdctl/container/container_export.go @@ -0,0 +1,94 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "fmt" + "os" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" +) + +func ExportCommand() *cobra.Command { + var exportCommand = &cobra.Command{ + Use: "export [OPTIONS] CONTAINER", + Args: cobra.ExactArgs(1), + Short: "Export a containers filesystem as a tar archive", + Long: "Export a containers filesystem as a tar archive", + RunE: exportAction, + ValidArgsFunction: exportShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + exportCommand.Flags().StringP("output", "o", "", "Write to a file, instead of STDOUT") + + return exportCommand +} + +func exportAction(cmd *cobra.Command, args []string) error { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + if len(args) == 0 { + return fmt.Errorf("requires at least 1 argument") + } + + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) + if err != nil { + return err + } + defer cancel() + + writer := cmd.OutOrStdout() + if output != "" { + f, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + writer = f + } else { + if isatty.IsTerminal(os.Stdout.Fd()) { + return fmt.Errorf("cowardly refusing to save to a terminal. Use the -o flag or redirect") + } + } + + options := types.ContainerExportOptions{ + Stdout: writer, + GOptions: globalOptions, + } + + return container.Export(ctx, client, args[0], options) +} + +func exportShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // show container names + return completion.ContainerNames(cmd, nil) +} diff --git a/cmd/nerdctl/container/container_export_test.go b/cmd/nerdctl/container/container_export_test.go new file mode 100644 index 00000000000..ee25fe2dc9d --- /dev/null +++ b/cmd/nerdctl/container/container_export_test.go @@ -0,0 +1,170 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "archive/tar" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// validateExportedTar checks that the tar file exists and contains /bin/busybox +func validateExportedTar(outFile string) test.Comparator { + return func(stdout string, t tig.T) { + // Check if the tar file was created + _, err := os.Stat(outFile) + assert.Assert(t, !os.IsNotExist(err), "exported tar file %s was not created", outFile) + + // Open and read the tar file to check for /bin/busybox + file, err := os.Open(outFile) + assert.NilError(t, err, "failed to open tar file %s", outFile) + defer file.Close() + + tarReader := tar.NewReader(file) + busyboxFound := false + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NilError(t, err, "failed to read tar entry") + + if header.Name == "bin/busybox" || header.Name == "./bin/busybox" { + busyboxFound = true + break + } + } + + assert.Assert(t, busyboxFound, "exported tar file %s does not contain /bin/busybox", outFile) + t.Log("Export validation passed: tar file exists and contains /bin/busybox") + } +} + +func TestExportStoppedContainer(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("export is not supported on Windows") + } + + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier("container") + helpers.Ensure("create", "--name", identifier, testutil.CommonImage) + data.Labels().Set("cID", identifier) + data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar")) + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Labels().Get("cID")) + helpers.Anyhow("rm", "-f", data.Labels().Get("cID")) + os.Remove(data.Labels().Get("outFile")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "export command succeeds", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "tar file exists and has content", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Use a simple command that always succeeds to trigger the validation + return helpers.Custom("echo", "validating tar file") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: validateExportedTar(data.Labels().Get("outFile")), + } + }, + }, + } + + testCase.Run(t) +} + +func TestExportRunningContainer(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("export is not supported on Windows") + } + + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier("container") + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity) + data.Labels().Set("cID", identifier) + data.Labels().Set("outFile", filepath.Join(os.TempDir(), identifier+".tar")) + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Labels().Get("cID")) + os.Remove(data.Labels().Get("outFile")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "export command succeeds", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("export", "-o", data.Labels().Get("outFile"), data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "tar file exists and has content", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Use a simple command that always succeeds to trigger the validation + return helpers.Custom("echo", "validating tar file") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: validateExportedTar(data.Labels().Get("outFile")), + } + }, + }, + } + + testCase.Run(t) +} + +func TestExportNonexistentContainer(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("export is not supported on Windows") + } + + testCase := nerdtest.Setup() + testCase.Command = test.Command("export", "nonexistent-container") + testCase.Expected = test.Expects(1, nil, nil) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_health_check.go b/cmd/nerdctl/container/container_health_check.go new file mode 100644 index 00000000000..abc0337168d --- /dev/null +++ b/cmd/nerdctl/container/container_health_check.go @@ -0,0 +1,85 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +// HealthCheckCommand returns a cobra command for `nerdctl container healthcheck` +func HealthCheckCommand() *cobra.Command { + var healthCheckCommand = &cobra.Command{ + Use: "healthcheck [flags] CONTAINER", + Short: "Execute the health check command in a container", + Args: cobra.ExactArgs(1), + RunE: healthCheckAction, + ValidArgsFunction: healthCheckShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + + return healthCheckCommand +} + +func healthCheckAction(cmd *cobra.Command, args []string) error { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) + if err != nil { + return err + } + defer cancel() + + containerID := args[0] + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + return container.HealthCheck(ctx, client, found.Container) + }, + } + + n, err := walker.Walk(ctx, containerID) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("no such container %s", containerID) + } + return nil +} + +func healthCheckShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ContainerNames(cmd, func(status containerd.ProcessStatus) bool { + return status == containerd.Running + }) +} diff --git a/cmd/nerdctl/container/container_health_check_linux_test.go b/cmd/nerdctl/container/container_health_check_linux_test.go new file mode 100644 index 00000000000..1217045a3ed --- /dev/null +++ b/cmd/nerdctl/container/container_health_check_linux_test.go @@ -0,0 +1,1157 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/healthcheck" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestContainerHealthCheckBasic(t *testing.T) { + testCase := nerdtest.Setup() + + // Docker CLI does not provide a standalone healthcheck command. + testCase.Require = require.Not(nerdtest.Docker) + + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Container does not exist", + Command: test.Command("container", "healthcheck", "non-existent"), + Expected: test.Expects(1, []error{errors.New("no such container non-existent")}, nil), + }, + { + Description: "Missing health check config", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: test.Expects(1, []error{errors.New("container has no health check configured")}, nil), + }, + { + Description: "Basic health check success", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "45s", + "--health-timeout", "30s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state to be present") + assert.Equal(t, healthcheck.Healthy, h.Status) + assert.Equal(t, 0, h.FailingStreak) + assert.Assert(t, len(h.Log) > 0, "expected at least one health check log entry") + }), + } + }, + }, + { + Description: "Health check on stopped container", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "3s", + testutil.CommonImage, "sleep", "2") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + helpers.Ensure("stop", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: test.Expects(1, []error{errors.New("container is not running (status: stopped)")}, nil), + }, + { + Description: "Health check without task", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("create", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: test.Expects(1, []error{errors.New("failed to get container task: no running task found")}, nil), + }, + } + + testCase.Run(t) +} + +func TestContainerHealthCheckDefaults(t *testing.T) { + testCase := nerdtest.Setup() + + // Docker CLI does not provide a standalone healthcheck command. + testCase.Require = require.Not(nerdtest.Docker) + + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Health check applies default values when not explicitly set", + Setup: func(data test.Data, helpers test.Helpers) { + // Create container with only --health-cmd, no other health flags + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + + // Parse the healthcheck config from container labels + hcLabel := inspect.Config.Labels["nerdctl/healthcheck"] + assert.Assert(t, hcLabel != "", "expected healthcheck label to be present") + + var hc healthcheck.Healthcheck + err := json.Unmarshal([]byte(hcLabel), &hc) + assert.NilError(t, err, "failed to parse healthcheck config") + + // Verify default values are applied + assert.Equal(t, hc.Interval, 30*time.Second, "expected default interval of 30s") + assert.Equal(t, hc.Timeout, 30*time.Second, "expected default timeout of 30s") + assert.Equal(t, hc.Retries, 3, "expected default retries of 3") + assert.Equal(t, hc.StartPeriod, 0*time.Second, "expected default start period of 0s") + + // Verify the command was set correctly + assert.DeepEqual(t, hc.Test, []string{"CMD-SHELL", "echo healthy"}) + }), + } + }, + }, + { + Description: "CLI flags override default values correctly", + Setup: func(data test.Data, helpers test.Helpers) { + // Create container with custom health flags that override defaults + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo custom", + "--health-interval", "45s", + "--health-timeout", "15s", + "--health-retries", "5", + "--health-start-period", "10s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + + // Parse the healthcheck config from container labels + hcLabel := inspect.Config.Labels["nerdctl/healthcheck"] + assert.Assert(t, hcLabel != "", "expected healthcheck label to be present") + + var hc healthcheck.Healthcheck + err := json.Unmarshal([]byte(hcLabel), &hc) + assert.NilError(t, err, "failed to parse healthcheck config") + + // Verify CLI overrides are applied (not defaults) + assert.Equal(t, hc.Interval, 45*time.Second, "expected custom interval of 45s") + assert.Equal(t, hc.Timeout, 15*time.Second, "expected custom timeout of 15s") + assert.Equal(t, hc.Retries, 5, "expected custom retries of 5") + assert.Equal(t, hc.StartPeriod, 10*time.Second, "expected custom start period of 10s") + + // Verify the command was set correctly + assert.DeepEqual(t, hc.Test, []string{"CMD-SHELL", "echo custom"}) + }), + } + }, + }, + { + Description: "No defaults applied when no healthcheck is configured", + Setup: func(data test.Data, helpers test.Helpers) { + // Create container without any health flags + helpers.Ensure("run", "-d", "--name", data.Identifier(), + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + + // Verify no healthcheck label is present + hcLabel := inspect.Config.Labels["nerdctl/healthcheck"] + assert.Equal(t, hcLabel, "", "expected no healthcheck label when no healthcheck is configured") + + // Verify no health state + assert.Assert(t, inspect.State.Health == nil, "expected no health state when no healthcheck is configured") + }), + } + }, + }, + } + + testCase.Run(t) +} + +func TestContainerHealthCheckAdvance(t *testing.T) { + testCase := nerdtest.Setup() + + // Docker CLI does not provide a standalone healthcheck command. + testCase.Require = require.Not(nerdtest.Docker) + + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Health check timeout scenario", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "sleep 10", + "--health-timeout", "2s", + "--health-interval", "1s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Assert(t, h.FailingStreak >= 1, "expected at least one failing streak") + assert.Assert(t, len(inspect.State.Health.Log) > 0, "expected health log to have entries") + last := inspect.State.Health.Log[0] + assert.Equal(t, -1, last.ExitCode) + }), + } + }, + }, + { + Description: "Health check failing streak behavior", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "exit 1", + "--health-interval", "1s", + "--health-retries", "2", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Run healthcheck twice to ensure failing streak + for i := 0; i < 2; i++ { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(2 * time.Second) + } + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Unhealthy) + assert.Assert(t, h.FailingStreak >= 1, "expected atleast one FailingStreak") + }), + } + }, + }, + { + Description: "Health check with start period", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "exit 1", + "--health-interval", "1s", + "--health-start-period", "60s", + "--health-retries", "2", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Starting) + assert.Equal(t, h.FailingStreak, 0) + }), + } + }, + }, + { + Description: "Health check with invalid command", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "not-a-real-cmd", + "--health-interval", "1s", + "--health-retries", "1", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Unhealthy) + assert.Assert(t, h.FailingStreak >= 1, "expected at least one failing streak") + }), + } + }, + }, + { + Description: "No healthcheck flag disables health status", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--no-healthcheck", testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + assert.Assert(t, inspect.State.Health == nil, "expected health to be nil with --no-healthcheck") + }), + } + }, + }, + { + Description: "Healthcheck using CMD-SHELL format", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo shell-format", "--health-interval", "1s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(_ string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy) + assert.Assert(t, len(h.Log) > 0) + assert.Assert(t, strings.Contains(h.Log[0].Output, "shell-format")) + }), + } + }, + }, + { + Description: "Health check uses container environment variables", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--env", "MYVAR=test-value", + "--health-cmd", "echo $MYVAR", + "--health-interval", "1s", + "--health-timeout", "1s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy) + assert.Assert(t, h.FailingStreak == 0) + assert.Assert(t, strings.Contains(h.Log[0].Output, "test"), "expected health log output to contain 'test'") + }), + } + }, + }, + { + Description: "Health check respects container WorkingDir", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--workdir", "/tmp", + "--health-cmd", "pwd", + "--health-interval", "1s", + "--health-timeout", "1s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy) + assert.Equal(t, h.FailingStreak, 0) + assert.Assert(t, strings.Contains(h.Log[0].Output, "/tmp"), "expected health log output to contain '/tmp'") + }), + } + }, + }, + { + Description: "Healthcheck emits large output repeatedly", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "yes X | head -c 60000", + "--health-interval", "1s", "--health-timeout", "2s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + for i := 0; i < 3; i++ { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(2 * time.Second) + } + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(_ string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy) + assert.Assert(t, len(h.Log) >= 3, "expected at least 3 health log entries") + for _, log := range h.Log { + assert.Assert(t, len(log.Output) >= 1024, fmt.Sprintf("each output should be >= 1024 bytes, was: %s", log.Output)) + } + }), + } + }, + }, + { + Description: "Health log in inspect keeps only the latest 5 entries", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "exit 1", + "--health-interval", "1s", + "--health-retries", "1", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + for i := 0; i < 7; i++ { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(1 * time.Second) + } + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(_ string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Unhealthy) + assert.Assert(t, len(h.Log) <= 5, "expected health log to contain at most 5 entries") + }), + } + }, + }, + { + Description: "Healthcheck with large output gets truncated in health log", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "yes X | head -c 1048576", // 1MB output + "--health-interval", "1s", "--health-timeout", "2s", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "healthcheck", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(_ string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy) + assert.Equal(t, h.FailingStreak, 0) + assert.Assert(t, len(h.Log) >= 1, "expected at least one log entry") + output := h.Log[0].Output + assert.Assert(t, strings.HasSuffix(output, "[truncated]"), "expected output to be truncated with '[truncated]'") + }), + } + }, + }, + { + Description: "Health status transitions from healthy to unhealthy after retries", + Setup: func(data test.Data, helpers test.Helpers) { + containerName := data.Identifier() + helpers.Ensure("run", "-d", "--name", containerName, + "--health-cmd", "exit 1", + "--health-timeout", "10s", + "--health-retries", "3", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + for i := 0; i < 4; i++ { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(2 * time.Second) + } + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Unhealthy) + assert.Assert(t, h.FailingStreak >= 3) + }), + } + }, + }, + { + Description: "Failed healthchecks in start-period do not change status", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "ls /foo || exit 1", "--health-retries", "2", + "--health-start-period", "30s", // long enough to stay in "starting" + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Run healthcheck 3 times (should still be in start period) + for i := 0; i < 3; i++ { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(1 * time.Second) + } + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Starting) + assert.Equal(t, h.FailingStreak, 0, "failing streak should not increase during start period") + }), + } + }, + }, + { + Description: "Successful healthcheck in start-period sets status to healthy", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "ls || exit 1", "--health-retries", "2", + testutil.CommonImage, "sleep", nerdtest.Infinity) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("container", "healthcheck", data.Identifier()) + time.Sleep(1 * time.Second) + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h := inspect.State.Health + debug, _ := json.MarshalIndent(h, "", " ") + t.Log(string(debug)) + assert.Assert(t, h != nil, "expected health state") + assert.Equal(t, h.Status, healthcheck.Healthy, "expected healthy status even during start-period") + assert.Equal(t, h.FailingStreak, 0) + }), + } + }, + }, + } + + testCase.Run(t) +} + +func TestHealthCheck_SystemdIntegration_Basic(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.Docker) + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Basic healthy container with systemd-triggered healthcheck", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "2s", + testutil.CommonImage, "sleep", "30") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Ensure proper cleanup of systemd units + helpers.Anyhow("stop", data.Identifier()) + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + var h *healthcheck.Health + + // Poll up to 5 times for health status + maxAttempts := 5 + var finalStatus string + + for i := 0; i < maxAttempts; i++ { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + h = inspect.State.Health + + assert.Assert(t, h != nil, "expected health state to be present") + finalStatus = h.Status + + // If healthy, break and pass the test + if finalStatus == "healthy" { + t.Log(fmt.Sprintf("Container became healthy on attempt %d/%d", i+1, maxAttempts)) + break + } + + // If unhealthy, fail immediately + if finalStatus == "unhealthy" { + assert.Assert(t, false, fmt.Sprintf("Container became unhealthy on attempt %d/%d, status: %s", i+1, maxAttempts, finalStatus)) + return + } + + // If not the last attempt, wait before retrying + if i < maxAttempts-1 { + t.Log(fmt.Sprintf("Attempt %d/%d: status is '%s', waiting 1 second before retry", i+1, maxAttempts, finalStatus)) + time.Sleep(1 * time.Second) + } + } + + if finalStatus != "healthy" { + assert.Assert(t, false, fmt.Sprintf("Container did not become healthy after %d attempts, final status: %s", maxAttempts, finalStatus)) + return + } + + assert.Assert(t, len(h.Log) > 0, "expected at least one health check log entry") + }), + } + }, + }, + { + Description: "Kill stops healthcheck execution and cleans up systemd timer", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "1s", + testutil.CommonImage, "sleep", "30") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + helpers.Ensure("kill", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Container is already killed, just remove it + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + // Get container info for verification + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := inspect.ID + h := inspect.State.Health + + // Verify health state and logs exist + assert.Assert(t, h != nil, "expected health state to be present") + assert.Assert(t, len(h.Log) > 0, "expected at least one health check log entry") + + // Ensure systemd timers are removed + result := helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, _ tig.T) { + assert.Assert(t, !strings.Contains(stdout, containerID), + "expected nerdctl healthcheck timer for container ID %s to be removed after container stop", containerID) + }, + }) + }, + } + }, + }, + { + Description: "Remove cleans up systemd timer", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "1s", + testutil.CommonImage, "sleep", "30") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + helpers.Ensure("rm", "-f", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Container is already removed, no cleanup needed + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := inspect.ID + + // Check systemd timers to ensure cleanup + result := helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, _ tig.T) { + // Verify systemd timer has been cleaned up by checking systemctl output + // We check that no timer contains our test identifier + assert.Assert(t, !strings.Contains(stdout, containerID), + "expected nerdctl healthcheck timer for container ID %s to be removed after container removal", containerID) + }, + }) + }, + } + }, + }, + { + Description: "Stop cleans up systemd timer", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "1s", + testutil.CommonImage, "sleep", "30") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + helpers.Ensure("stop", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Container is already stopped, just remove it + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + // Get container info for verification + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := inspect.ID + + // Ensure systemd timers are removed + result := helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, _ tig.T) { + assert.Assert(t, !strings.Contains(stdout, containerID), + "expected nerdctl healthcheck timer for container ID %s to be removed after container stop", containerID) + }, + }) + }, + } + }, + }, + } + testCase.Run(t) +} + +func TestHealthCheck_GlobalFlags(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.Docker) + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Healthcheck works with custom namespace flag", + Setup: func(data test.Data, helpers test.Helpers) { + // Create container in custom namespace with healthcheck + helpers.Ensure("--namespace=healthcheck-test", "run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "2s", + testutil.CommonImage, "sleep", "30") + // Wait a bit to ensure container is running (can't use EnsureContainerStarted with custom namespace) + time.Sleep(1 * time.Second) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--namespace=healthcheck-test", "rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Wait a bit for healthcheck to run + time.Sleep(3 * time.Second) + // Verify container is accessible in the custom namespace + return helpers.Command("--namespace=healthcheck-test", "inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + var inspectResults []dockercompat.Container + err := json.Unmarshal([]byte(stdout), &inspectResults) + assert.NilError(t, err, "failed to parse inspect output") + assert.Assert(t, len(inspectResults) > 0, "expected at least one container in inspect results") + + inspect := inspectResults[0] + h := inspect.State.Health + assert.Assert(t, h != nil, "expected health state to be present") + assert.Assert(t, h.Status == healthcheck.Healthy || h.Status == healthcheck.Starting, + "expected health status to be healthy or starting, got: %s", h.Status) + assert.Assert(t, len(h.Log) > 0, "expected at least one health check log entry") + }, + } + }, + }, + { + Description: "Healthcheck works correctly with namespace after container restart", + Setup: func(data test.Data, helpers test.Helpers) { + // Create container in custom namespace + helpers.Ensure("--namespace=restart-test", "run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "2s", + testutil.CommonImage, "sleep", "60") + // Wait a bit to ensure container is running (can't use EnsureContainerStarted with custom namespace) + time.Sleep(1 * time.Second) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--namespace=restart-test", "rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Wait for initial healthcheck + time.Sleep(3 * time.Second) + + // Stop and restart the container + helpers.Ensure("--namespace=restart-test", "stop", data.Identifier()) + helpers.Ensure("--namespace=restart-test", "start", data.Identifier()) + // Wait a bit to ensure container is running after restart + time.Sleep(1 * time.Second) + + // Wait for healthcheck to run after restart + time.Sleep(3 * time.Second) + + return helpers.Command("--namespace=restart-test", "inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + // Parse the inspect JSON output directly since we're in a custom namespace + var inspectResults []dockercompat.Container + err := json.Unmarshal([]byte(stdout), &inspectResults) + assert.NilError(t, err, "failed to parse inspect output") + assert.Assert(t, len(inspectResults) > 0, "expected at least one container in inspect results") + + inspect := inspectResults[0] + h := inspect.State.Health + assert.Assert(t, h != nil, "expected health state after restart") + assert.Assert(t, h.Status == healthcheck.Healthy || h.Status == healthcheck.Starting, + "expected health status to be healthy or starting after restart, got: %s", h.Status) + assert.Assert(t, len(h.Log) > 0, "expected health check logs after restart") + }, + } + }, + }, + } + testCase.Run(t) +} + +func TestHealthCheck_SystemdIntegration_Advanced(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.Docker) + // Skip systemd tests in rootless environment to bypass dbus permission issues + if rootlessutil.IsRootless() { + t.Skip("systemd healthcheck tests are skipped in rootless environment") + } + + testCase.SubTests = []*test.Case{ + { + // Tests that CreateTimer() successfully creates systemd timer units and + // RemoveTransientHealthCheckFiles() properly cleans up units when container stops. + Description: "Systemd timer unit creation and cleanup", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo healthy", + "--health-interval", "1s", + testutil.CommonImage, "sleep", "30") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All(func(stdout string, t tig.T) { + // Get container ID and check systemd timer + containerInspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := containerInspect.ID + + // Check systemd timer + result := helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, _ tig.T) { + // Verify that a timer exists for this specific container + assert.Assert(t, strings.Contains(stdout, containerID), + "expected to find nerdctl healthcheck timer containing container ID: %s", containerID) + }, + }) + // Stop container and verify cleanup + helpers.Ensure("stop", data.Identifier()) + + // Check that timer is gone + result = helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, _ tig.T) { + assert.Assert(t, !strings.Contains(stdout, containerID), + "expected nerdctl healthcheck timer for container ID %s to be removed after container stop", containerID) + }, + }) + }), + } + }, + }, + { + Description: "Container restart recreates systemd timer", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "--health-cmd", "echo restart-test", + "--health-interval", "2s", + testutil.CommonImage, "sleep", "60") + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Get container ID for verification + containerInspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := containerInspect.ID + + // Step 1: Verify timer exists initially + result := helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, containerID), + "expected timer for container %s to exist initially", containerID) + }, + }) + + // Step 2: Stop container + helpers.Ensure("stop", data.Identifier()) + + // Step 3: Verify timer is removed after stop + result = helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + result.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + assert.Assert(t, !strings.Contains(stdout, containerID), + "expected timer for container %s to be removed after stop", containerID) + }, + }) + + // Step 4: Restart container + helpers.Ensure("start", data.Identifier()) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + + // Step 5: Verify timer is recreated after restart - this is our final verification + return helpers.Custom("systemctl", "list-timers", "--all", "--no-pager") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + containerInspect := nerdtest.InspectContainer(helpers, data.Identifier()) + containerID := containerInspect.ID + assert.Assert(t, strings.Contains(stdout, containerID), + "expected timer for container %s to be recreated after restart", containerID) + }, + } + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_inspect_linux_test.go b/cmd/nerdctl/container/container_inspect_linux_test.go index 7ccf35eeea9..1c82925bf46 100644 --- a/cmd/nerdctl/container/container_inspect_linux_test.go +++ b/cmd/nerdctl/container/container_inspect_linux_test.go @@ -17,10 +17,9 @@ package container import ( + "encoding/json" "fmt" "os" - "os/exec" - "path/filepath" "slices" "strings" "testing" @@ -28,8 +27,10 @@ import ( "github.com/docker/go-connections/nat" "gotest.tools/v3/assert" + "github.com/containerd/continuity/testutil/loopback" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/infoutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" @@ -186,6 +187,35 @@ func TestContainerInspectContainsInternalLabel(t *testing.T) { assert.Equal(base.T, expectedLabelMount, labelMount) } +func TestContainerInspectConfigImage(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "Container inspect contains Config.Image field", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.AlpineImage, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier()) + }, + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var containers []dockercompat.Container + err := json.Unmarshal([]byte(stdout), &containers) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(containers), "Expected exactly one container in inspect output") + + container := containers[0] + assert.Assert(t, container.Config != nil, "container Config should not be nil") + assert.Assert(t, container.Config.Image != "", "Config.Image should not be empty") + }), + } + + testCase.Run(t) +} + func TestContainerInspectState(t *testing.T) { t.Parallel() testContainer := testutil.Identifier(t) @@ -332,8 +362,12 @@ func TestContainerInspectHostConfigDefaults(t *testing.T) { assert.Equal(t, "", inspect.HostConfig.UTSMode) assert.Equal(t, hc.ShmSize, inspect.HostConfig.ShmSize) assert.Equal(t, hc.Runtime, inspect.HostConfig.Runtime) - assert.Equal(t, 0, len(inspect.HostConfig.Sysctls)) assert.Equal(t, 0, len(inspect.HostConfig.Devices)) + // Sysctls can be empty or contain "net.ipv4.ip_unprivileged_port_start" depending on the environment. + got := len(inspect.HostConfig.Sysctls) + if got != 0 && got != 1 { + t.Fatalf("unexpected number of Sysctls entries: %d (want 0 or 1)", got) + } } func TestContainerInspectHostConfigDNS(t *testing.T) { @@ -482,45 +516,45 @@ func TestContainerInspectBlkioSettings(t *testing.T) { // For now, disable the test unless on a recent kernel. testutil.RequireKernelVersion(t, ">= 6.0.0-0") - devPath := "/dev/dummy-zero" - // a dummy zero device: mknod /dev/dummy-zero c 1 5 - helperCmd := exec.Command("mknod", []string{devPath, "c", "1", "5"}...) - if out, err := helperCmd.CombinedOutput(); err != nil { - err = fmt.Errorf("cannot create %q: %q: %w", devPath, string(out), err) + lo, err := loopback.New(4096) + if err != nil { + err = fmt.Errorf("cannot find a loop device: %w", err) t.Fatal(err) } - - // ensure the file will be removed in case of failed in the test - defer func() { - if err := exec.Command("rm", "-f", devPath).Run(); err != nil { - t.Logf("failed to remove device %s: %v", devPath, err) - } - }() + defer lo.Close() base := testutil.NewBase(t) defer base.Cmd("rm", "-f", testContainer).AssertOK() + const ( + weight = 500 + readBps = 1048576 + readIops = 1000 + writeBps = 2097152 + writeIops = 2000 + ) base.Cmd("run", "-d", "--name", testContainer, - "--blkio-weight", "500", - "--blkio-weight-device", "/dev/dummy-zero:500", - "--device-read-bps", "/dev/dummy-zero:1048576", - "--device-read-iops", "/dev/dummy-zero:1000", - "--device-write-bps", "/dev/dummy-zero:2097152", - "--device-write-iops", "/dev/dummy-zero:2000", + "--blkio-weight", fmt.Sprintf("%d", weight), + "--blkio-weight-device", fmt.Sprintf("%s:%d", lo.Device, weight), + "--device-read-bps", fmt.Sprintf("%s:%d", lo.Device, readBps), + "--device-read-iops", fmt.Sprintf("%s:%d", lo.Device, readIops), + "--device-write-bps", fmt.Sprintf("%s:%d", lo.Device, writeBps), + "--device-write-iops", fmt.Sprintf("%s:%d", lo.Device, writeIops), testutil.AlpineImage, "sleep", "infinity").AssertOK() inspect := base.InspectContainer(testContainer) - assert.Equal(t, uint16(500), inspect.HostConfig.BlkioWeight) + assert.Equal(t, uint16(weight), inspect.HostConfig.BlkioWeight) assert.Equal(t, 1, len(inspect.HostConfig.BlkioWeightDevice)) - assert.Equal(t, uint16(500), *inspect.HostConfig.BlkioWeightDevice[0].Weight) + assert.Equal(t, lo.Device, inspect.HostConfig.BlkioWeightDevice[0].Path) + assert.Equal(t, uint16(weight), inspect.HostConfig.BlkioWeightDevice[0].Weight) assert.Equal(t, 1, len(inspect.HostConfig.BlkioDeviceReadBps)) - assert.Equal(t, uint64(1048576), inspect.HostConfig.BlkioDeviceReadBps[0].Rate) + assert.Equal(t, uint64(readBps), inspect.HostConfig.BlkioDeviceReadBps[0].Rate) assert.Equal(t, 1, len(inspect.HostConfig.BlkioDeviceWriteBps)) - assert.Equal(t, uint64(2097152), inspect.HostConfig.BlkioDeviceWriteBps[0].Rate) + assert.Equal(t, uint64(writeBps), inspect.HostConfig.BlkioDeviceWriteBps[0].Rate) assert.Equal(t, 1, len(inspect.HostConfig.BlkioDeviceReadIOps)) - assert.Equal(t, uint64(1000), inspect.HostConfig.BlkioDeviceReadIOps[0].Rate) + assert.Equal(t, uint64(readIops), inspect.HostConfig.BlkioDeviceReadIOps[0].Rate) assert.Equal(t, 1, len(inspect.HostConfig.BlkioDeviceWriteIOps)) - assert.Equal(t, uint64(2000), inspect.HostConfig.BlkioDeviceWriteIOps[0].Rate) + assert.Equal(t, uint64(writeIops), inspect.HostConfig.BlkioDeviceWriteIOps[0].Rate) } func TestContainerInspectUser(t *testing.T) { @@ -535,8 +569,7 @@ RUN groupadd -r test && useradd -r -g test test USER test `, testutil.UbuntuImage) - err := os.WriteFile(filepath.Join(data.Temp().Path(), "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", "-t", data.Identifier(), data.Temp().Path()) helpers.Ensure("create", "--name", data.Identifier(), "--user", "test", data.Identifier()) diff --git a/cmd/nerdctl/container/container_list_linux_test.go b/cmd/nerdctl/container/container_list_linux_test.go index e7ce1c92e11..cee48d7eb9e 100644 --- a/cmd/nerdctl/container/container_list_linux_test.go +++ b/cmd/nerdctl/container/container_list_linux_test.go @@ -27,6 +27,7 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/formatter" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -304,6 +305,42 @@ func TestContainerListWithFilter(t *testing.T) { return nil }) + // should support regexp + base.Cmd("ps", "--filter", "name=.*"+testContainerA.name+".*").AssertOutWithFunc(func(stdout string) error { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 2 { + return fmt.Errorf("expected at least 2 lines, got %d", len(lines)) + } + + tab := tabutil.NewReader("CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES") + err := tab.ParseHeader(lines[0]) + if err != nil { + return fmt.Errorf("failed to parse header: %v", err) + } + + containerName, _ := tab.ReadRow(lines[1], "NAMES") + assert.Equal(t, containerName, testContainerA.name) + return nil + }) + + // fully anchored regexp + base.Cmd("ps", "--filter", "name=^"+testContainerA.name+"$").AssertOutWithFunc(func(stdout string) error { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 2 { + return fmt.Errorf("expected at least 2 lines, got %d", len(lines)) + } + + tab := tabutil.NewReader("CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES") + err := tab.ParseHeader(lines[0]) + if err != nil { + return fmt.Errorf("failed to parse header: %v", err) + } + + containerName, _ := tab.ReadRow(lines[1], "NAMES") + assert.Equal(t, containerName, testContainerA.name) + return nil + }) + base.Cmd("ps", "-q", "--filter", "name="+testContainerA.name+testContainerA.name).AssertOutWithFunc(func(stdout string) error { lines := strings.Split(strings.TrimSpace(stdout), "\n") if len(lines) > 0 { @@ -652,7 +689,7 @@ func TestContainerListStatusFilter(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(stdout, data.Labels().Get("cID")), "No container found with status created") }, } diff --git a/cmd/nerdctl/container/container_list_test.go b/cmd/nerdctl/container/container_list_test.go index 751cfabc64c..ebd78c17944 100644 --- a/cmd/nerdctl/container/container_list_test.go +++ b/cmd/nerdctl/container/container_list_test.go @@ -20,43 +20,67 @@ import ( "fmt" "testing" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) // https://github.com/containerd/nerdctl/issues/2598 func TestContainerListWithFormatLabel(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - cID := tID - labelK := "label-key-" + tID - labelV := "label-value-" + tID - - base.Cmd("run", "-d", - "--name", cID, - "--label", labelK+"="+labelV, - testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() - defer base.Cmd("rm", "-f", cID).AssertOK() - base.Cmd("ps", "-a", - "--filter", "label="+labelK, - "--format", fmt.Sprintf("{{.Label %q}}", labelK)).AssertOutExactly(labelV + "\n") + nerdtest.Setup() + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + labelK := "label-key-" + data.Identifier() + labelV := "label-value-" + data.Identifier() + helpers.Ensure("run", "-d", + "--name", data.Identifier(), + "--label", labelK+"="+labelV, + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + labelK := "label-key-" + data.Identifier() + return helpers.Command("ps", "-a", + "--filter", "label="+labelK, + "--format", fmt.Sprintf("{{.Label %q}}", labelK)) //nolint:dupামিটার + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + labelV := "label-value-" + data.Identifier() + return test.Expects(0, nil, expect.Equals(labelV+"\n"))(data, helpers) + }, + } + testCase.Run(t) } func TestContainerListWithJsonFormatLabel(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - cID := tID - labelK := "label-key-" + tID - labelV := "label-value-" + tID - - base.Cmd("run", "-d", - "--name", cID, - "--label", labelK+"="+labelV, - testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() - defer base.Cmd("rm", "-f", cID).AssertOK() - base.Cmd("ps", "-a", - "--filter", "label="+labelK, - "--format", "json").AssertOutContains(fmt.Sprintf("%s=%s", labelK, labelV)) + nerdtest.Setup() + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + labelK := "label-key-" + data.Identifier() + labelV := "label-value-" + data.Identifier() + helpers.Ensure("run", "-d", + "--name", data.Identifier(), + "--label", labelK+"="+labelV, + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + labelK := "label-key-" + data.Identifier() + return helpers.Command("ps", "-a", + "--filter", "label="+labelK, + "--format", "json") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + labelK := "label-key-" + data.Identifier() + labelV := "label-value-" + data.Identifier() + return test.Expects(0, nil, expect.Contains(fmt.Sprintf("%s=%s", labelK, labelV)))(data, helpers) + }, + } + testCase.Run(t) } diff --git a/cmd/nerdctl/container/container_logs_test.go b/cmd/nerdctl/container/container_logs_test.go index 0ea37dab378..0110a9f4cdf 100644 --- a/cmd/nerdctl/container/container_logs_test.go +++ b/cmd/nerdctl/container/container_logs_test.go @@ -19,8 +19,7 @@ package container import ( "errors" "fmt" - "io" - "os/exec" + "regexp" "runtime" "strconv" "strings" @@ -28,52 +27,97 @@ import ( "time" "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestLogs(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) const expected = `foo -bar` +bar +` - defer base.Cmd("rm", containerName).Run() - base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, - "sh", "-euxc", "echo foo; echo bar").AssertOK() - - //test since / until flag - time.Sleep(3 * time.Second) - base.Cmd("logs", "--since", "1s", containerName).AssertOutNotContains(expected) - base.Cmd("logs", "--since", "10s", containerName).AssertOutContains(expected) - base.Cmd("logs", "--until", "10s", containerName).AssertOutNotContains(expected) - base.Cmd("logs", "--until", "1s", containerName).AssertOutContains(expected) + testCase := nerdtest.Setup() - // Ensure follow flag works as expected: - base.Cmd("logs", "-f", containerName).AssertOutContains("bar") - base.Cmd("logs", "-f", containerName).AssertOutContains("foo") + if runtime.GOOS == "windows" { + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4237") + } - //test timestamps flag - base.Cmd("logs", "-t", containerName).AssertOutContains(time.Now().UTC().Format("2006-01-02")) + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } - //test tail flag - base.Cmd("logs", "-n", "all", containerName).AssertOutContains(expected) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--quiet", "--name", data.Identifier(), testutil.CommonImage, "sh", "-euxc", "echo foo; echo bar;") + data.Labels().Set("cID", data.Identifier()) + } - base.Cmd("logs", "-n", "1", containerName).AssertOutWithFunc(func(stdout string) error { - if !(stdout == "bar\n" || stdout == "") { - return fmt.Errorf("expected %q or %q, got %q", "bar", "", stdout) - } - return nil - }) + testCase.SubTests = []*test.Case{ + { + Description: "since 1s", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "--since", "1s", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.DoesNotContain(expected)), + }, + { + Description: "since 60s", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "--since", "60s", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals(expected)), + }, + { + Description: "until 60s", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "--until", "60s", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.DoesNotContain(expected)), + }, + { + Description: "until 1s", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "--until", "1s", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals(expected)), + }, + { + Description: "follow", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-f", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals(expected)), + }, + { + Description: "timestamp", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-t", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Contains(time.Now().UTC().Format("2006-01-02"))), + }, + { + Description: "tail flag", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-n", "all", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals(expected)), + }, + { + Description: "tail flag", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-n", "1", data.Labels().Get("cID")) + }, + // FIXME: why? + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile("^(?:bar\n|)$"))), + }, + } - base.Cmd("rm", "-f", containerName).AssertOK() + testCase.Run(t) } // Tests whether `nerdctl logs` properly separates stdout/stderr output @@ -81,8 +125,13 @@ bar` func TestLogsOutStreamsSeparated(t *testing.T) { testCase := nerdtest.Setup() + if runtime.GOOS == "windows" { + // Logging seems broken on windows. + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4237") + } + testCase.Setup = func(data test.Data, helpers test.Helpers) { - helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, "sh", "-euc", "echo stdout1; echo stderr1 >&2; echo stdout2; echo stderr2 >&2") } @@ -91,8 +140,6 @@ func TestLogsOutStreamsSeparated(t *testing.T) { } testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { - // Arbitrary, but we need to wait until the logs show up - time.Sleep(3 * time.Second) return helpers.Command("logs", data.Identifier()) } @@ -105,116 +152,165 @@ func TestLogsOutStreamsSeparated(t *testing.T) { } func TestLogsWithInheritedFlags(t *testing.T) { - // Seen flaky with Docker - t.Parallel() - base := testutil.NewBase(t) - for k, v := range base.Args { - if strings.HasPrefix(v, "--namespace=") { - base.Args[k] = "-n=" + testutil.Namespace - } + testCase := nerdtest.Setup() + + testCase.Require = require.Not(nerdtest.Docker) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("-n="+testutil.Namespace, "run", "--name", data.Identifier(), testutil.CommonImage, + "sh", "-euxc", "echo foo; echo bar") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) } - containerName := testutil.Identifier(t) - defer base.Cmd("rm", containerName).Run() - base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, - "sh", "-euxc", "echo foo; echo bar").AssertOK() + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("-n="+testutil.Namespace, "logs", "-n", "1", data.Identifier()) + } - // It appears this test flakes out with Docker seeing only "foo\n" - // Tentatively adding a pause in case this is just slow - time.Sleep(time.Second) - // test rootCmd alias `-n` already used in logs subcommand - base.Cmd("logs", "-n", "1", containerName).AssertOutWithFunc(func(stdout string) error { - if !(stdout == "bar\n" || stdout == "") { - return fmt.Errorf("expected %q or %q, got %q", "bar", "", stdout) - } - return nil - }) + // FIXME: why? + testCase.Expected = test.Expects(0, nil, expect.Match(regexp.MustCompile("^(?:bar\n|)$"))) + + testCase.Run(t) } func TestLogsOfJournaldDriver(t *testing.T) { - testutil.RequireExecutable(t, "journalctl") - journalctl, _ := exec.LookPath("journalctl") - res := icmd.RunCmd(icmd.Command(journalctl, "-xe")) - if res.ExitCode != 0 { - t.Skipf("current user is not allowed to access journal logs: %s", res.Combined()) - } + const expected = `foo +bar +` - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) + testCase := nerdtest.Setup() - defer base.Cmd("rm", containerName).Run() - base.Cmd("run", "-d", "--network", "none", "--log-driver", "journald", "--name", containerName, testutil.CommonImage, - "sh", "-euxc", "echo foo; echo bar").AssertOK() + testCase.Require = require.All( + require.Binary("journalctl"), + &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + works := false + cmd := helpers.Custom("journalctl", "-xe") + cmd.Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + if stdout != "" { + works = true + } + }, + }) + return works, "Journactl to return data for the current user" + }, + }, + ) - time.Sleep(3 * time.Second) - base.Cmd("logs", containerName).AssertOutContains("bar") - // Run logs twice, make sure that the logs are not removed - base.Cmd("logs", containerName).AssertOutContains("foo") + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } - base.Cmd("logs", "--since", "5s", containerName).AssertOutWithFunc(func(stdout string) error { - if !strings.Contains(stdout, "bar") { - return fmt.Errorf("expected bar, got %s", stdout) - } - if !strings.Contains(stdout, "foo") { - return fmt.Errorf("expected foo, got %s", stdout) - } - return nil - }) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--network", "none", "--log-driver", "journald", "--name", data.Identifier(), testutil.CommonImage, + "sh", "-euxc", "echo foo; echo bar") + data.Labels().Set("cID", data.Identifier()) + } - base.Cmd("rm", "-f", containerName).AssertOK() + testCase.SubTests = []*test.Case{ + { + Description: "logs", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", data.Labels().Get("cID")) + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.Equals(expected)), + }, + { + Description: "logs --since 60s", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "--since", "60s", data.Labels().Get("cID")) + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.DoesNotContain("foo", "bar")), + }, + } } func TestLogsWithFailingContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - defer base.Cmd("rm", containerName).Run() - base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, - "sh", "-euxc", "echo foo; echo bar; exit 42; echo baz").AssertOK() - time.Sleep(3 * time.Second) - // AssertOutContains also asserts that the exit code of the logs command == 0, - // even when the container is failing - base.Cmd("logs", "-f", containerName).AssertOutContains("bar") - base.Cmd("logs", "-f", containerName).AssertOutNotContains("baz") - base.Cmd("rm", "-f", containerName).AssertOK() + const expected = `foo +bar +` + + testCase := nerdtest.Setup() + + if runtime.GOOS == "windows" { + // Logging seems broken on windows. + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4237") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("run", "--name", data.Identifier(), testutil.CommonImage, "sh", "-euxc", "echo foo; echo bar; exit 42; echo baz") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", data.Identifier()) + } + + testCase.Expected = test.Expects(0, nil, expect.Equals(expected)) + + testCase.Run(t) } func TestLogsWithRunningContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - defer base.Cmd("rm", "-f", containerName).Run() expected := make([]string, 10) for i := 0; i < 10; i++ { expected[i] = fmt.Sprint(i + 1) } - base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, - "sh", "-euc", "for i in `seq 1 10`; do echo $i; sleep 1; done").AssertOK() - base.Cmd("logs", "-f", containerName).AssertOutContainsAll(expected...) + testCase := nerdtest.Setup() + + if runtime.GOOS == "windows" { + // Logging seems broken on windows. + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4237") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, "sh", "-euc", "for i in `seq 1 10`; do echo $i; sleep 1; done") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", data.Identifier()) + } + + testCase.Expected = test.Expects(0, nil, expect.Contains(expected[0], expected[1:]...)) + + testCase.Run(t) } func TestLogsWithoutNewlineOrEOF(t *testing.T) { testCase := nerdtest.Setup() + // FIXME: test does not work on Windows yet because containerd doesn't send an exit event appropriately after task exit on Windows") // FIXME: nerdctl behavior does not match docker - test disabled for nerdctl until we fix testCase.Require = require.All( require.Linux, - nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4201"), ) + testCase.Setup = func(data test.Data, helpers test.Helpers) { - helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "printf", "'Hello World!\nThere is no newline'") + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, "printf", "'Hello World!\nThere is no newline'") } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rm", "-f", data.Identifier()) } + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { - // FIXME: arbitrary timeouts are by nature a problem. - time.Sleep(5 * time.Second) return helpers.Command("logs", "-f", data.Identifier()) } + testCase.Expected = test.Expects(0, nil, expect.Equals("'Hello World!\nThere is no newline'")) + testCase.Run(t) } @@ -222,19 +318,44 @@ func TestLogsAfterRestartingContainer(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("FIXME: test does not work on Windows yet. Restarting a container fails with: failed to create shim task: hcs::CreateComputeSystem : The requested operation for attach namespace failed.: unknown") } - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - defer base.Cmd("rm", "-f", containerName).Run() - base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, - "printf", "'Hello World!\nThere is no newline'").AssertOK() - expected := []string{"Hello World!", "There is no newline"} - time.Sleep(3 * time.Second) - base.Cmd("logs", "-f", containerName).AssertOutContainsAll(expected...) - // restart and check logs again - base.Cmd("start", containerName) - time.Sleep(3 * time.Second) - base.Cmd("logs", "-f", containerName).AssertOutContainsAll(expected...) + + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, + "printf", "'Hello World!\nThere is no newline'") + data.Labels().Set("cID", data.Identifier()) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.SubTests = []*test.Case{ + { + Description: "logs -f works", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-f", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals("'Hello World!\nThere is no newline'")), + }, + { + Description: "logs -f works after restart", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("start", data.Labels().Get("cID")) + // FIXME: this is inherently flaky + time.Sleep(5 * time.Second) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", "-f", data.Labels().Get("cID")) + }, + Expected: test.Expects(0, nil, expect.Equals("'Hello World!\nThere is no newline''Hello World!\nThere is no newline'")), + }, + } + + testCase.Run(t) } func TestLogsWithForegroundContainers(t *testing.T) { @@ -256,10 +377,7 @@ func TestLogsWithForegroundContainers(t *testing.T) { Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("logs", data.Identifier()) }, - Expected: test.Expects(0, nil, expect.All( - expect.Contains("foo", "bar"), - expect.DoesNotContain("baz"), - )), + Expected: test.Expects(0, nil, expect.Equals("foo\nbar\n")), }, { Description: "interactive", @@ -272,10 +390,7 @@ func TestLogsWithForegroundContainers(t *testing.T) { Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("logs", data.Identifier()) }, - Expected: test.Expects(0, nil, expect.All( - expect.Contains("foo", "bar"), - expect.DoesNotContain("baz"), - )), + Expected: test.Expects(0, nil, expect.Equals("foo\nbar\n")), }, { Description: "PTY", @@ -290,10 +405,7 @@ func TestLogsWithForegroundContainers(t *testing.T) { Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("logs", data.Identifier()) }, - Expected: test.Expects(0, nil, expect.All( - expect.Contains("foo", "bar"), - expect.DoesNotContain("baz"), - )), + Expected: test.Expects(0, nil, expect.Equals("foo\nbar\n")), }, { Description: "interactivePTY", @@ -308,69 +420,88 @@ func TestLogsWithForegroundContainers(t *testing.T) { Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("logs", data.Identifier()) }, - Expected: test.Expects(0, nil, expect.All( - expect.Contains("foo", "bar"), - expect.DoesNotContain("baz"), - )), + Expected: test.Expects(0, nil, expect.Equals("foo\nbar\n")), }, } } -func TestTailFollowRotateLogs(t *testing.T) { - // FIXME this is flaky by nature... 2 lines is arbitrary, 10000 ms is arbitrary, and both are some sort of educated - // guess that things will mostly always kinda work maybe... - // Furthermore, parallelizing will put pressure on the daemon which might be even slower in answering, increasing - // the risk of transient failure. - // This test needs to be rethought entirely - // t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("tail log is not supported on Windows") - } - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - +func TestLogsTailFollowRotate(t *testing.T) { + // FIXME this is flaky by nature... the number of lines is arbitrary, the wait is arbitrary, + // and both are some sort of educated guess that things will mostly always kinda work maybe... const sampleJSONLog = `{"log":"A\n","stream":"stdout","time":"2024-04-11T12:01:09.800288974Z"}` const linesPerFile = 200 - defer base.Cmd("rm", "-f", containerName).Run() - base.Cmd("run", "-d", "--log-driver", "json-file", - "--log-opt", fmt.Sprintf("max-size=%d", len(sampleJSONLog)*linesPerFile), - "--log-opt", "max-file=10", - "--name", containerName, testutil.CommonImage, - "sh", "-euc", "while true; do echo A; usleep 100; done").AssertOK() - - tailLogCmd := base.Cmd("logs", "-f", containerName) - tailLogCmd.Timeout = 1000 * time.Millisecond - logRun := tailLogCmd.Run() - tailLogs := strings.Split(strings.TrimSpace(logRun.Stdout()), "\n") - for _, line := range tailLogs { - if line != "" { - assert.Equal(t, "A", line) - } + testCase := nerdtest.Setup() + + // tail log is not supported on Windows + testCase.Require = require.Not(require.Windows) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--log-driver", "json-file", + "--log-opt", fmt.Sprintf("max-size=%d", len(sampleJSONLog)*linesPerFile), + "--log-opt", "max-file=10", + "--name", data.Identifier(), testutil.CommonImage, + "sh", "-euc", "while true; do echo A; usleep 100; done") + // FIXME: ... inherently racy... + time.Sleep(5 * time.Second) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) } - assert.Equal(t, true, len(tailLogs) > linesPerFile, logRun.Stderr()) + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("logs", "-f", data.Identifier()) + // FIXME: this is flaky by nature. We assume that the container has started and will output enough in 5 seconds. + cmd.WithTimeout(5 * time.Second) + return cmd + } + + testCase.Expected = test.Expects(expect.ExitCodeTimeout, nil, func(stdout string, t tig.T) { + tailLogs := strings.Split(strings.TrimSpace(stdout), "\n") + for _, line := range tailLogs { + if line != "" { + assert.Equal(t, "A", line) + } + } + + assert.Assert(t, len(tailLogs) > linesPerFile, fmt.Sprintf("expected %d lines or more, found %d", linesPerFile, len(tailLogs))) + }) + + testCase.Run(t) } -func TestNoneLoggerHasNoLogURI(t *testing.T) { + +func TestLogsNoneLoggerHasNoLogURI(t *testing.T) { testCase := nerdtest.Setup() testCase.Setup = func(data test.Data, helpers test.Helpers) { helpers.Ensure("run", "--name", data.Identifier(), "--log-driver", "none", testutil.CommonImage, "sh", "-euxc", "echo foo") } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rm", "-f", data.Identifier()) } + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("logs", data.Identifier()) } + testCase.Expected = test.Expects(1, nil, nil) + testCase.Run(t) } func TestLogsWithDetails(t *testing.T) { testCase := nerdtest.Setup() + // FIXME: this is not working on windows. There is some deep issue with windows logs: + // https://github.com/containerd/nerdctl/issues/4237 + if runtime.GOOS == "windows" { + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/4237") + } + testCase.Setup = func(data test.Data, helpers test.Helpers) { - helpers.Ensure("run", "-d", "--log-driver", "json-file", + helpers.Ensure("run", "--log-driver", "json-file", "--log-opt", "max-size=10m", "--log-opt", "max-file=3", "--log-opt", "env=ENV", @@ -394,10 +525,36 @@ func TestLogsWithDetails(t *testing.T) { testCase.Run(t) } +func TestLogsFollowNoExtraneousLineFeed(t *testing.T) { + testCase := nerdtest.Setup() + // This test verifies that `nerdctl logs -f` does not add extraneous line feeds + testCase.Require = require.Not(require.Windows) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // Create a container that outputs a message without a trailing newline + helpers.Ensure("run", "--name", data.Identifier(), testutil.CommonImage, + "sh", "-c", "printf 'Hello without newline'") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Use logs -f to follow the logs + return helpers.Command("logs", "-f", data.Identifier()) + } + + // Verify that the output is exactly "Hello without newline" without any additional line feeds + testCase.Expected = test.Expects(0, nil, expect.Equals("Hello without newline")) + + testCase.Run(t) +} + func TestLogsWithStartContainer(t *testing.T) { testCase := nerdtest.Setup() - // For windows we havent added support for dual logging so not adding the test. + // Windows does not support dual logging. testCase.Require = require.Not(require.Windows) testCase.SubTests = []*test.Case{ @@ -406,34 +563,28 @@ func TestLogsWithStartContainer(t *testing.T) { Setup: func(data test.Data, helpers test.Helpers) { cmd := helpers.Command("run", "-it", "--name", data.Identifier(), testutil.CommonImage) cmd.WithPseudoTTY() - cmd.WithFeeder(func() io.Reader { - return strings.NewReader("echo foo\nexit\n") + cmd.Feed(strings.NewReader("echo foo\nexit\n")) + cmd.Run(&test.Expected{ + ExitCode: 0, }) + cmd = helpers.Command("start", "-ia", data.Identifier()) + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo bar\nexit\n")) cmd.Run(&test.Expected{ ExitCode: 0, }) - }, Cleanup: func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rm", "-f", data.Identifier()) }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - cmd := helpers.Command("start", "-ia", data.Identifier()) - cmd.WithPseudoTTY() - cmd.WithFeeder(func() io.Reader { - return strings.NewReader("echo bar\nexit\n") - }) - cmd.Run(&test.Expected{ - ExitCode: 0, - }) - cmd = helpers.Command("logs", data.Identifier()) - - return cmd + return helpers.Command("logs", data.Identifier()) }, Expected: test.Expects(0, nil, expect.Contains("foo", "bar")), }, { + // FIXME: is this test safe or could it be racy? Description: "Test logs are captured after stopping and starting a non-interactive container and continue capturing new logs", Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sh", "-c", "while true; do echo foo; sleep 1; done") @@ -453,10 +604,10 @@ func TestLogsWithStartContainer(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { finalLogsCount := strings.Count(stdout, "foo") initialFooCount, _ := strconv.Atoi(data.Labels().Get("initialFooCount")) - assert.Assert(t, finalLogsCount > initialFooCount, "Expected 'foo' count to increase after restart", info) + assert.Assert(t, finalLogsCount > initialFooCount, "Expected 'foo' count to increase after restart") }, } }, diff --git a/cmd/nerdctl/container/container_port.go b/cmd/nerdctl/container/container_port.go index a6237749789..180cacb3d12 100644 --- a/cmd/nerdctl/container/container_port.go +++ b/cmd/nerdctl/container/container_port.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/portutil" ) func PortCommand() *cobra.Command { @@ -81,13 +82,26 @@ func portAction(cmd *cobra.Command, args []string) error { } defer cancel() + dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) + if err != nil { + return err + } + walker := &containerwalker.ContainerWalker{ Client: client, OnFound: func(ctx context.Context, found containerwalker.Found) error { if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - return containerutil.PrintHostPort(ctx, cmd.OutOrStdout(), found.Container, argPort, argProto) + containerLabels, err := found.Container.Labels(ctx) + if err != nil { + return err + } + ports, err := portutil.LoadPortMappings(dataStore, globalOptions.Namespace, found.Container.ID(), containerLabels) + if err != nil { + return err + } + return containerutil.PrintHostPort(ctx, cmd.OutOrStdout(), found.Container, argPort, argProto, ports) }, } req := args[0] diff --git a/cmd/nerdctl/container/container_remove_linux_test.go b/cmd/nerdctl/container/container_remove_linux_test.go new file mode 100644 index 00000000000..53bf4928242 --- /dev/null +++ b/cmd/nerdctl/container/container_remove_linux_test.go @@ -0,0 +1,123 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" +) + +// iptablesCheckCommand is the shell command to check iptables rules +const iptablesCheckCommand = "iptables -t nat -S && iptables -t filter -S && iptables -t mangle -S" + +// testContainerRmIptablesExecutor is a common executor function for testing iptables rules cleanup +func testContainerRmIptablesExecutor(data test.Data, helpers test.Helpers) test.TestableCommand { + t := helpers.T() + + // Get the container ID from the label + containerID := data.Labels().Get("containerID") + + // Remove the container + helpers.Ensure("rm", "-f", containerID) + + time.Sleep(1 * time.Second) + + // Create a TestableCommand using helpers.Custom + if rootlessutil.IsRootless() { + // In rootless mode, we need to enter the rootlesskit network namespace + if netns, err := rootlessutil.DetachedNetNS(); err != nil { + t.Log(fmt.Sprintf("Failed to get detached network namespace: %v", err)) + t.FailNow() + } else { + if netns != "" { + // Use containerd-rootless-setuptool.sh to enter the RootlessKit namespace + return helpers.Custom("containerd-rootless-setuptool.sh", "nsenter", "--", "nsenter", "--net="+netns, "sh", "-ec", iptablesCheckCommand) + } + // Enter into :RootlessKit namespace using containerd-rootless-setuptool.sh + return helpers.Custom("containerd-rootless-setuptool.sh", "nsenter", "--", "sh", "-ec", iptablesCheckCommand) + } + } + + // In non-rootless mode, check iptables rules directly on the host + return helpers.Custom("sh", "-ec", iptablesCheckCommand) +} + +// TestContainerRmIptables tests that iptables rules are cleared after container deletion +func TestContainerRmIptables(t *testing.T) { + testCase := nerdtest.Setup() + + // Require iptables and containerd-rootless-setuptool.sh commands to be available + testCase.Require = require.All( + require.Binary("iptables"), + require.Binary("containerd-rootless-setuptool.sh"), + require.Not(require.Windows), + require.Not(nerdtest.Docker), + ) + + testCase.SubTests = []*test.Case{ + { + Description: "Test iptables rules are cleared after container deletion", + Setup: func(data test.Data, helpers test.Helpers) { + // Get a free port using portlock + port, err := portlock.Acquire(0) + if err != nil { + helpers.T().Log(fmt.Sprintf("Failed to acquire port: %v", err)) + helpers.T().FailNow() + } + data.Labels().Set("port", strconv.Itoa(port)) + + // Create a container with port mapping to ensure iptables rules are created + containerID := helpers.Capture("run", "-d", "--name", data.Identifier(), "-p", fmt.Sprintf("%d:80", port), testutil.NginxAlpineImage) + data.Labels().Set("containerID", containerID) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Make sure container is removed even if test fails + helpers.Anyhow("rm", "-f", data.Identifier()) + + // Release the acquired port + if portStr := data.Labels().Get("port"); portStr != "" { + port, _ := strconv.Atoi(portStr) + _ = portlock.Release(port) + } + }, + Command: testContainerRmIptablesExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + // Get the container ID from the label + containerID := data.Labels().Get("containerID") + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + // Verify that the iptables output does not contain the container ID + Output: expect.DoesNotContain(containerID), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_restart.go b/cmd/nerdctl/container/container_restart.go index cbb4b28aeda..f4ca608f451 100644 --- a/cmd/nerdctl/container/container_restart.go +++ b/cmd/nerdctl/container/container_restart.go @@ -48,6 +48,9 @@ func restartOptions(cmd *cobra.Command) (types.ContainerRestartOptions, error) { return types.ContainerRestartOptions{}, err } + // Call GlobalFlags function here + nerdctlCmd, nerdctlArgs := helpers.GlobalFlags(cmd) + var timeout *time.Duration if cmd.Flags().Changed("time") { // Seconds to wait for stop before killing it @@ -70,10 +73,12 @@ func restartOptions(cmd *cobra.Command) (types.ContainerRestartOptions, error) { } return types.ContainerRestartOptions{ - Stdout: cmd.OutOrStdout(), - GOption: globalOptions, - Timeout: timeout, - Signal: signal, + Stdout: cmd.OutOrStdout(), + GOption: globalOptions, + Timeout: timeout, + Signal: signal, + NerdctlCmd: nerdctlCmd, + NerdctlArgs: nerdctlArgs, }, err } diff --git a/cmd/nerdctl/container/container_restart_linux_test.go b/cmd/nerdctl/container/container_restart_linux_test.go index f4d82482f1f..954c9760db7 100644 --- a/cmd/nerdctl/container/container_restart_linux_test.go +++ b/cmd/nerdctl/container/container_restart_linux_test.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -153,12 +154,12 @@ func TestRestartWithSignal(t *testing.T) { Output: expect.All( // Check that we saw SIGUSR1 inside the container expect.Contains(nerdtest.SignalCaught), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { // Ensure the container was restarted nerdtest.EnsureContainerStarted(helpers, data.Identifier()) // Check the new pid is different newpid := strconv.Itoa(nerdtest.InspectContainer(helpers, data.Identifier()).State.Pid) - assert.Assert(helpers.T(), newpid != data.Labels().Get("oldpid"), info) + assert.Assert(helpers.T(), newpid != data.Labels().Get("oldpid")) }, ), } diff --git a/cmd/nerdctl/container/container_run.go b/cmd/nerdctl/container/container_run.go index be629b7eb2f..81904491256 100644 --- a/cmd/nerdctl/container/container_run.go +++ b/cmd/nerdctl/container/container_run.go @@ -33,10 +33,12 @@ import ( "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/consoleutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/defaults" "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/logging" "github.com/containerd/nerdctl/v2/pkg/netutil" @@ -234,6 +236,14 @@ func setCreateFlags(cmd *cobra.Command) { // rootfs flags (from Podman) cmd.Flags().Bool("rootfs", false, "The first argument is not an image but the rootfs to the exploded container") + // Health check flags + cmd.Flags().String("health-cmd", "", "Command to run to check health") + cmd.Flags().Duration("health-interval", 0, "Time between running the check (default: 30s)") + cmd.Flags().Duration("health-timeout", 0, "Maximum time to allow one check to run (default: 30s)") + cmd.Flags().Int("health-retries", 0, "Consecutive failures needed to report unhealthy (default: 3)") + cmd.Flags().Duration("health-start-period", 0, "Start period for the container to initialize before starting health-retries countdown") + cmd.Flags().Bool("no-healthcheck", false, "Disable any container-specified HEALTHCHECK") + // #region env flags // entrypoint needs to be StringArray, not StringSlice, to prevent "FOO=foo1,foo2" from being split to {"FOO=foo1", "foo2"} // entrypoint StringArray is an internal implementation to support `nerdctl compose` entrypoint yaml filed with multiple strings @@ -367,7 +377,7 @@ func runAction(cmd *cobra.Command, args []string) error { return errors.New("flags -d and -a cannot be specified together") } - netFlags, err := loadNetworkFlags(cmd) + netFlags, err := loadNetworkFlags(cmd, createOpt.GOptions) if err != nil { return fmt.Errorf("failed to load networking flags: %w", err) } @@ -421,15 +431,39 @@ func runAction(cmd *cobra.Command, args []string) error { } logURI := lab[labels.LogURI] detachC := make(chan struct{}) - task, err := taskutil.NewTask(ctx, client, c, createOpt.Attach, createOpt.Interactive, createOpt.TTY, createOpt.Detach, - con, logURI, createOpt.DetachKeys, createOpt.GOptions.Namespace, detachC) + task, err := taskutil.NewTask(ctx, client, c, taskutil.TaskOptions{ + AttachStreamOpt: createOpt.Attach, + IsInteractive: createOpt.Interactive, + IsTerminal: createOpt.TTY, + IsDetach: createOpt.Detach, + Con: con, + LogURI: logURI, + DetachKeys: createOpt.DetachKeys, + Namespace: createOpt.GOptions.Namespace, + DetachC: detachC, + CheckpointDir: "", + }) + if err != nil { + return err + } + + statusC, err := task.Wait(ctx) if err != nil { return err } + if err := task.Start(ctx); err != nil { return err } + // Setup container healthchecks. + if err := healthcheck.CreateTimer(ctx, c, (*config.Config)(&createOpt.GOptions), createOpt.NerdctlCmd, createOpt.NerdctlArgs); err != nil { + return fmt.Errorf("failed to create healthcheck timer: %w", err) + } + if err := healthcheck.StartTimer(ctx, c, (*config.Config)(&createOpt.GOptions)); err != nil { + return fmt.Errorf("failed to start healthcheck timer: %w", err) + } + if createOpt.Detach { fmt.Fprintln(createOpt.Stdout, id) return nil @@ -445,10 +479,6 @@ func runAction(cmd *cobra.Command, args []string) error { } } - statusC, err := task.Wait(ctx) - if err != nil { - return err - } select { // io.Wait() would return when either 1) the user detaches from the container OR 2) the container is about to exit. // diff --git a/cmd/nerdctl/container/container_run_cgroup_linux_test.go b/cmd/nerdctl/container/container_run_cgroup_linux_test.go index 9f9e9812f13..5b8807c1227 100644 --- a/cmd/nerdctl/container/container_run_cgroup_linux_test.go +++ b/cmd/nerdctl/container/container_run_cgroup_linux_test.go @@ -21,8 +21,8 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -35,9 +35,11 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/cmd/container" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -60,9 +62,6 @@ func TestRunCgroupV2(t *testing.T) { if !info.SwapLimit { t.Skip("test requires SwapLimit") } - if !info.CPUShares { - t.Skip("test requires CPUShares") - } if !info.CPUSet { t.Skip("test requires CPUSet") } @@ -73,7 +72,6 @@ func TestRunCgroupV2(t *testing.T) { 44040192 44040192 42 -77 0-1 0 ` @@ -82,34 +80,32 @@ func TestRunCgroupV2(t *testing.T) { 60817408 6291456 42 -77 0-1 0 ` - // In CgroupV2 CPUWeight replace CPUShares => weight := 1 + ((shares-2)*9999)/262142 base.Cmd("run", "--rm", "--cpus", "0.42", "--cpuset-mems", "0", "--memory", "42m", "--pids-limit", "42", - "--cpu-shares", "2000", "--cpuset-cpus", "0-1", + "--cpuset-cpus", "0-1", "-w", "/sys/fs/cgroup", testutil.AlpineImage, "cat", "cpu.max", "memory.max", "memory.swap.max", - "pids.max", "cpu.weight", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected1) + "pids.max", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected1) base.Cmd("run", "--rm", "--cpu-quota", "42000", "--cpuset-mems", "0", "--cpu-period", "100000", "--memory", "42m", "--memory-reservation", "6m", "--memory-swap", "100m", - "--pids-limit", "42", "--cpu-shares", "2000", "--cpuset-cpus", "0-1", + "--pids-limit", "42", "--cpuset-cpus", "0-1", "-w", "/sys/fs/cgroup", testutil.AlpineImage, "cat", "cpu.max", "memory.max", "memory.swap.max", "memory.low", "pids.max", - "cpu.weight", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected2) + "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected2) base.Cmd("run", "--name", testutil.Identifier(t)+"-testUpdate1", "-w", "/sys/fs/cgroup", "-d", testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", testutil.Identifier(t)+"-testUpdate1").Run() update := []string{"update", "--cpu-quota", "42000", "--cpuset-mems", "0", "--cpu-period", "100000", "--memory", "42m", - "--pids-limit", "42", "--cpu-shares", "2000", "--cpuset-cpus", "0-1"} + "--pids-limit", "42", "--cpuset-cpus", "0-1"} if nerdtest.IsDocker() && info.CgroupVersion == "2" && info.SwapLimit { // Workaround for Docker with cgroup v2: // > Error response from daemon: Cannot update container 67c13276a13dd6a091cdfdebb355aa4e1ecb15fbf39c2b5c9abee89053e88fce: @@ -120,7 +116,7 @@ func TestRunCgroupV2(t *testing.T) { base.Cmd(update...).AssertOK() base.Cmd("exec", testutil.Identifier(t)+"-testUpdate1", "cat", "cpu.max", "memory.max", "memory.swap.max", - "pids.max", "cpu.weight", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected1) + "pids.max", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected1) defer base.Cmd("rm", "-f", testutil.Identifier(t)+"-testUpdate2").Run() base.Cmd("run", "--name", testutil.Identifier(t)+"-testUpdate2", "-w", "/sys/fs/cgroup", "-d", @@ -129,12 +125,14 @@ func TestRunCgroupV2(t *testing.T) { base.Cmd("update", "--cpu-quota", "42000", "--cpuset-mems", "0", "--cpu-period", "100000", "--memory", "42m", "--memory-reservation", "6m", "--memory-swap", "100m", - "--pids-limit", "42", "--cpu-shares", "2000", "--cpuset-cpus", "0-1", + "--pids-limit", "42", "--cpuset-cpus", "0-1", testutil.Identifier(t)+"-testUpdate2").AssertOK() base.Cmd("exec", testutil.Identifier(t)+"-testUpdate2", "cat", "cpu.max", "memory.max", "memory.swap.max", "memory.low", - "pids.max", "cpu.weight", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected2) - + "pids.max", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected2) + base.Cmd("run", "--rm", "--security-opt", "writable-cgroups=true", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/foo").AssertOK() + base.Cmd("run", "--rm", "--security-opt", "writable-cgroups=false", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/foo").AssertFail() + base.Cmd("run", "--rm", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/foo").AssertFail() } func TestRunCgroupV1(t *testing.T) { @@ -176,6 +174,9 @@ func TestRunCgroupV1(t *testing.T) { const expected = "42000\n100000\n0\n44040192\n6291456\n104857600\n0\n42\n2000\n0-1\n" base.Cmd("run", "--rm", "--cpus", "0.42", "--cpuset-mems", "0", "--memory", "42m", "--memory-reservation", "6m", "--memory-swap", "100m", "--memory-swappiness", "0", "--pids-limit", "42", "--cpu-shares", "2000", "--cpuset-cpus", "0-1", testutil.AlpineImage, "cat", quota, period, cpusetMems, memoryLimit, memoryReservation, memorySwap, memorySwappiness, pidsLimit, cpuShare, cpusetCpus).AssertOutExactly(expected) base.Cmd("run", "--rm", "--cpu-quota", "42000", "--cpu-period", "100000", "--cpuset-mems", "0", "--memory", "42m", "--memory-reservation", "6m", "--memory-swap", "100m", "--memory-swappiness", "0", "--pids-limit", "42", "--cpu-shares", "2000", "--cpuset-cpus", "0-1", testutil.AlpineImage, "cat", quota, period, cpusetMems, memoryLimit, memoryReservation, memorySwap, memorySwappiness, pidsLimit, cpuShare, cpusetCpus).AssertOutExactly(expected) + base.Cmd("run", "--rm", "--security-opt", "writable-cgroups=true", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/pids/foo").AssertOK() + base.Cmd("run", "--rm", "--security-opt", "writable-cgroups=false", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/pids/foo").AssertFail() + base.Cmd("run", "--rm", testutil.AlpineImage, "mkdir", "/sys/fs/cgroup/pids/foo").AssertFail() } // TestIssue3781 tests https://github.com/containerd/nerdctl/issues/3781 @@ -310,7 +311,7 @@ func TestRunDevice(t *testing.T) { Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("exec", data.Labels().Get("id"), "sh", "-ec", "echo -n \"overwritten-lo1-content\">"+lo[1].Device) }, - Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(expect.ExitCodeSuccess, nil, func(stdout string, t tig.T) { lo1Read, err := os.ReadFile(lo[1].Device) assert.NilError(t, err) assert.Equal(t, string(bytes.Trim(lo1Read, "\x00")), "overwritten-lo1-content") @@ -489,31 +490,33 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { // For now, disable the test unless on a recent kernel. testutil.RequireKernelVersion(t, ">= 6.0.0-0") - // Create dummy device path - dummyDev := "/dev/dummy-zero" - + const ( + weight = "150" + deviceWeight = "100" + readBps = "1048576" + readIops = "1000" + writeBps = "2097152" + writeIops = "2000" + ) + var lo *loopback.Loopback testCase.Setup = func(data test.Data, helpers test.Helpers) { - // Create dummy device - helperCmd := exec.Command("mknod", dummyDev, "c", "1", "5") - if out, err := helperCmd.CombinedOutput(); err != nil { - t.Fatalf("cannot create %q: %q: %v", dummyDev, string(out), err) - } + var err error + lo, err = loopback.New(4096) + assert.NilError(t, err) + t.Logf("loopback device: %+v", lo) } - testCase.Cleanup = func(data test.Data, helpers test.Helpers) { - // Clean up the dummy device - if err := exec.Command("rm", "-f", dummyDev).Run(); err != nil { - t.Logf("failed to remove device %s: %v", dummyDev, err) + if lo != nil { + _ = lo.Close() } } - testCase.SubTests = []*test.Case{ { Description: "blkio-weight", Require: nerdtest.CGroupV2, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--blkio-weight", "150", + "--blkio-weight", weight, testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -523,8 +526,8 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { - assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "{{.HostConfig.BlkioWeight}}", data.Identifier()), "150")) + func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "{{.HostConfig.BlkioWeight}}", data.Identifier()), weight)) }, ), } @@ -535,7 +538,7 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { Require: nerdtest.CGroupV2, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--blkio-weight-device", dummyDev+":100", + "--blkio-weight-device", fmt.Sprintf("%s:%s", lo.Device, deviceWeight), testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -545,9 +548,13 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioWeightDevice}}{{.Weight}}{{end}}", data.Identifier()) - assert.Assert(t, strings.Contains(inspectOut, "100")) + assert.Assert(t, strings.Contains(inspectOut, deviceWeight)) + }, + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioWeightDevice}}{{.Path}}{{end}}", data.Identifier()) + assert.Assert(t, strings.Contains(inspectOut, lo.Device)) }, ), } @@ -564,7 +571,7 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { ), Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--device-read-bps", dummyDev+":1048576", + "--device-read-bps", fmt.Sprintf("%s:%s", lo.Device, readBps), testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -574,9 +581,13 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceReadBps}}{{.Rate}}{{end}}", data.Identifier()) - assert.Assert(t, strings.Contains(inspectOut, "1048576")) + assert.Assert(t, strings.Contains(inspectOut, readBps)) + }, + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceReadBps}}{{.Path}}{{end}}", data.Identifier()) + assert.Assert(t, strings.Contains(inspectOut, lo.Device)) }, ), } @@ -593,7 +604,7 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { ), Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--device-write-bps", dummyDev+":2097152", + "--device-write-bps", fmt.Sprintf("%s:%s", lo.Device, writeBps), testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -603,9 +614,13 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceWriteBps}}{{.Rate}}{{end}}", data.Identifier()) - assert.Assert(t, strings.Contains(inspectOut, "2097152")) + assert.Assert(t, strings.Contains(inspectOut, writeBps)) + }, + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceWriteBps}}{{.Path}}{{end}}", data.Identifier()) + assert.Assert(t, strings.Contains(inspectOut, lo.Device)) }, ), } @@ -622,7 +637,7 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { ), Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--device-read-iops", dummyDev+":1000", + "--device-read-iops", fmt.Sprintf("%s:%s", lo.Device, readIops), testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -632,9 +647,13 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceReadIOps}}{{.Rate}}{{end}}", data.Identifier()) - assert.Assert(t, strings.Contains(inspectOut, "1000")) + assert.Assert(t, strings.Contains(inspectOut, readIops)) + }, + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceReadIOps}}{{.Path}}{{end}}", data.Identifier()) + assert.Assert(t, strings.Contains(inspectOut, lo.Device)) }, ), } @@ -651,7 +670,7 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { ), Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("run", "-d", "--name", data.Identifier(), - "--device-write-iops", dummyDev+":2000", + "--device-write-iops", fmt.Sprintf("%s:%s", lo.Device, writeIops), testutil.AlpineImage, "sleep", "infinity") }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -661,9 +680,13 @@ func TestRunBlkioSettingCgroupV2(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceWriteIOps}}{{.Rate}}{{end}}", data.Identifier()) - assert.Assert(t, strings.Contains(inspectOut, "2000")) + assert.Assert(t, strings.Contains(inspectOut, writeIops)) + }, + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("inspect", "--format", "{{range .HostConfig.BlkioDeviceWriteIOps}}{{.Path}}{{end}}", data.Identifier()) + assert.Assert(t, strings.Contains(inspectOut, lo.Device)) }, ), } @@ -696,7 +719,7 @@ func TestRunCPURealTimeSettingCgroupV1(t *testing.T) { return &test.Expected{ ExitCode: 0, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { rtRuntime := helpers.Capture("inspect", "--format", "{{.HostConfig.CPURealtimeRuntime}}", data.Identifier()) rtPeriod := helpers.Capture("inspect", "--format", "{{.HostConfig.CPURealtimePeriod}}", data.Identifier()) assert.Assert(t, strings.Contains(rtRuntime, "950000")) @@ -709,3 +732,30 @@ func TestRunCPURealTimeSettingCgroupV1(t *testing.T) { testCase.Run(t) } + +func TestRunCPUSharesCgroupV2(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: require.All( + nerdtest.CGroupV2, + nerdtest.Info( + func(info dockercompat.Info) error { + if !info.CPUShares { + return fmt.Errorf("test requires CPUShares") + } + return nil + }, + ), + ), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", "--cpu-shares", "2000", + testutil.AlpineImage, "cat", "/sys/fs/cgroup/cpu.weight") + }, + // The value was historically 77, but with runc v1.4.0-rc.1 it became 170. + // https://github.com/opencontainers/runc/issues/4896#issuecomment-3301825811 + Expected: test.Expects(0, nil, expect.Match(regexp.MustCompile("^(77|170)\n$"))), + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_run_linux_test.go b/cmd/nerdctl/container/container_run_linux_test.go index b025f681cfa..4210be47ece 100644 --- a/cmd/nerdctl/container/container_run_linux_test.go +++ b/cmd/nerdctl/container/container_run_linux_test.go @@ -36,6 +36,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" @@ -548,7 +549,7 @@ func TestRunWithDetachKeys(t *testing.T) { Errors: []error{errors.New("detach keys")}, Output: expect.All( expect.Contains("markmark"), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, ), @@ -616,7 +617,7 @@ func TestIssue3568(t *testing.T) { Errors: []error{errors.New("detach keys")}, Output: expect.All( expect.Contains("markmark"), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, ), @@ -651,8 +652,8 @@ func TestPortBindingWithCustomHost(t *testing.T) { ExitCode: 0, Errors: []error{}, Output: expect.All( - func(stdout string, info string, t *testing.T) { - resp, err := nettestutil.HTTPGet(address, 30, false) + func(stdout string, t tig.T) { + resp, err := nettestutil.HTTPGet(address, 5, false) assert.NilError(t, err) respBody, err := io.ReadAll(resp.Body) diff --git a/cmd/nerdctl/container/container_run_mount_linux_test.go b/cmd/nerdctl/container/container_run_mount_linux_test.go index c941d8d39fb..f66f62e46b2 100644 --- a/cmd/nerdctl/container/container_run_mount_linux_test.go +++ b/cmd/nerdctl/container/container_run_mount_linux_test.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" @@ -307,7 +308,7 @@ func TestRunBindMountTmpfs(t *testing.T) { } func mountExistsWithOpt(mountPoint, mountOpt string) test.Comparator { - return func(stdout, info string, t *testing.T) { + return func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") mountOutput := []string{} for _, line := range lines { @@ -352,6 +353,8 @@ func TestRunBindMountBind(t *testing.T) { "top", ) + nerdtest.EnsureContainerStarted(helpers, data.Identifier("container")) + // Save host rwDir location and container id for subtests data.Labels().Set("container", data.Identifier("container")) data.Labels().Set("rwDir", rwDir) diff --git a/cmd/nerdctl/container/container_run_network.go b/cmd/nerdctl/container/container_run_network.go index 1efddf25434..09f6a1b4b59 100644 --- a/cmd/nerdctl/container/container_run_network.go +++ b/cmd/nerdctl/container/container_run_network.go @@ -17,6 +17,8 @@ package container import ( + "errors" + "fmt" "net" "github.com/spf13/cobra" @@ -24,11 +26,12 @@ import ( "github.com/containerd/go-cni" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/dnsutil" "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/strutil" ) -func loadNetworkFlags(cmd *cobra.Command) (types.NetworkOptions, error) { +func loadNetworkFlags(cmd *cobra.Command, globalOpts types.GlobalCommandOptions) (types.NetworkOptions, error) { netOpts := types.NetworkOptions{} // --net/--network= ... @@ -101,33 +104,66 @@ func loadNetworkFlags(cmd *cobra.Command) (types.NetworkOptions, error) { netOpts.Domainname = domainname // --dns= ... - dnsSlice, err := cmd.Flags().GetStringSlice("dns") - if err != nil { - return netOpts, err + // Use command flags if set, otherwise use global config is set + var dnsSlice []string + if cmd.Flags().Changed("dns") { + var err error + dnsSlice, err = cmd.Flags().GetStringSlice("dns") + if err != nil { + return netOpts, err + } + if len(dnsSlice) == 0 { + return netOpts, errors.New("--dns flag was specified but no DNS server was provided") + } + for _, dns := range dnsSlice { + if _, err := dnsutil.ValidateIPAddress(dns); err != nil { + return netOpts, fmt.Errorf("%w with --dns flag", err) + } + } + } else { + dnsSlice = globalOpts.DNS } netOpts.DNSServers = strutil.DedupeStrSlice(dnsSlice) // --dns-search= ... - dnsSearchSlice, err := cmd.Flags().GetStringSlice("dns-search") - if err != nil { - return netOpts, err + // Use command flags if set, otherwise use global config is set + var dnsSearchSlice []string + if cmd.Flags().Changed("dns-search") { + var err error + dnsSearchSlice, err = cmd.Flags().GetStringSlice("dns-search") + if err != nil { + return netOpts, err + } + } else { + dnsSearchSlice = globalOpts.DNSSearch } netOpts.DNSSearchDomains = strutil.DedupeStrSlice(dnsSearchSlice) // --dns-opt/--dns-option= ... + // Use command flags if set, otherwise use global config if set dnsOptions := []string{} - dnsOptFlags, err := cmd.Flags().GetStringSlice("dns-opt") - if err != nil { - return netOpts, err - } - dnsOptions = append(dnsOptions, dnsOptFlags...) + // Check if either dns-opt or dns-option flags were set + dnsOptChanged := cmd.Flags().Changed("dns-opt") + dnsOptionChanged := cmd.Flags().Changed("dns-option") - dnsOptionFlags, err := cmd.Flags().GetStringSlice("dns-option") - if err != nil { - return netOpts, err + if dnsOptChanged || dnsOptionChanged { + // Use command flags + dnsOptFlags, err := cmd.Flags().GetStringSlice("dns-opt") + if err != nil { + return netOpts, err + } + dnsOptions = append(dnsOptions, dnsOptFlags...) + + dnsOptionFlags, err := cmd.Flags().GetStringSlice("dns-option") + if err != nil { + return netOpts, err + } + dnsOptions = append(dnsOptions, dnsOptionFlags...) + } else { + // Use global config defaults + dnsOptions = append(dnsOptions, globalOpts.DNSOpts...) } - dnsOptions = append(dnsOptions, dnsOptionFlags...) netOpts.DNSResolvConfOptions = strutil.DedupeStrSlice(dnsOptions) diff --git a/cmd/nerdctl/container/container_run_network_base_test.go b/cmd/nerdctl/container/container_run_network_base_test.go index 60a27be5202..ae939cf4c4d 100644 --- a/cmd/nerdctl/container/container_run_network_base_test.go +++ b/cmd/nerdctl/container/container_run_network_base_test.go @@ -155,7 +155,7 @@ func baseTestRunPort(t *testing.T, nginxImage string, nginxIndexHTMLSnippet stri hostPort: "7000-7005", containerPort: "80-85", connectURLPort: 7001, - err: "error after 30 attempts", + err: "error after 5 attempts", runShouldSuccess: true, }, { @@ -209,7 +209,7 @@ func baseTestRunPort(t *testing.T, nginxImage string, nginxIndexHTMLSnippet stri return } - resp, err := nettestutil.HTTPGet(connectURL, 30, false) + resp, err := nettestutil.HTTPGet(connectURL, 5, false) if tc.err != "" { assert.ErrorContains(t, err, tc.err) return diff --git a/cmd/nerdctl/container/container_run_network_linux_test.go b/cmd/nerdctl/container/container_run_network_linux_test.go index 46b9057e11a..13d94a2cab0 100644 --- a/cmd/nerdctl/container/container_run_network_linux_test.go +++ b/cmd/nerdctl/container/container_run_network_linux_test.go @@ -36,10 +36,10 @@ import ( "github.com/containerd/containerd/v2/defaults" "github.com/containerd/containerd/v2/pkg/netns" - "github.com/containerd/errdefs" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -247,7 +247,7 @@ func TestRunPortWithNoHostPort(t *testing.T) { return } connectURL := fmt.Sprintf("http://%s:%s", "127.0.0.1", paramsMap["portNumber"]) - resp, err := nettestutil.HTTPGet(connectURL, 30, false) + resp, err := nettestutil.HTTPGet(connectURL, 5, false) assert.NilError(t, err) respBody, err := io.ReadAll(resp.Body) assert.NilError(t, err) @@ -332,7 +332,7 @@ func TestUniqueHostPortAssignement(t *testing.T) { // Make HTTP GET request to container 1 connectURL1 := fmt.Sprintf("http://%s:%s", "127.0.0.1", port1) - resp1, err := nettestutil.HTTPGet(connectURL1, 30, false) + resp1, err := nettestutil.HTTPGet(connectURL1, 5, false) assert.NilError(t, err) respBody1, err := io.ReadAll(resp1.Body) assert.NilError(t, err) @@ -340,7 +340,7 @@ func TestUniqueHostPortAssignement(t *testing.T) { // Make HTTP GET request to container 2 connectURL2 := fmt.Sprintf("http://%s:%s", "127.0.0.1", port2) - resp2, err := nettestutil.HTTPGet(connectURL2, 30, false) + resp2, err := nettestutil.HTTPGet(connectURL2, 5, false) assert.NilError(t, err) respBody2, err := io.ReadAll(resp2.Body) assert.NilError(t, err) @@ -349,29 +349,81 @@ func TestUniqueHostPortAssignement(t *testing.T) { } } +func TestHostPortAlreadyInUse(t *testing.T) { + testCases := []struct { + hostPort string + containerPort string + }{ + { + hostPort: "5000", + containerPort: "80/tcp", + }, + { + hostPort: "5000", + containerPort: "80/tcp", + }, + { + hostPort: "5000", + containerPort: "80/udp", + }, + { + hostPort: "5000", + containerPort: "80/sctp", + }, + } + + tID := testutil.Identifier(t) + + for i, tc := range testCases { + tc := tc + tcName := fmt.Sprintf("%+v", tc) + t.Run(tcName, func(t *testing.T) { + if strings.Contains(tc.containerPort, "sctp") && rootlessutil.IsRootless() { + t.Skip("sctp is not supported in rootless mode") + } + testContainerName1 := fmt.Sprintf("%s-%d-1", tID, i) + testContainerName2 := fmt.Sprintf("%s-%d-2", tID, i) + base := testutil.NewBase(t) + t.Cleanup(func() { + base.Cmd("rm", "-f", testContainerName1, testContainerName2).AssertOK() + }) + pFlag := fmt.Sprintf("%s:%s", tc.hostPort, tc.containerPort) + cmd1 := base.Cmd("run", "-d", + "--name", testContainerName1, "-p", + pFlag, + testutil.NginxAlpineImage) + + cmd2 := base.Cmd("run", "-d", + "--name", testContainerName2, "-p", + pFlag, + testutil.NginxAlpineImage) + + cmd1.AssertOK() + cmd2.AssertFail() + }) + } +} + func TestRunPort(t *testing.T) { baseTestRunPort(t, testutil.NginxAlpineImage, testutil.NginxAlpineIndexHTMLSnippet, true) } -func TestRunWithInvalidPortThenCleanUp(t *testing.T) { +func TestRunWithManyPortsThenCleanUp(t *testing.T) { testCase := nerdtest.Setup() // docker does not set label restriction to 4096 bytes testCase.Require = require.Not(nerdtest.Docker) testCase.SubTests = []*test.Case{ { - Description: "Run a container with invalid ports, and then clean up.", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "--data-root", data.Temp().Path(), "-f", data.Identifier()) - }, + Description: "Run a container with many ports, and then clean up.", Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Command("run", "--data-root", data.Temp().Path(), "--rm", "--name", data.Identifier(), "-p", "22200-22299:22200-22299", testutil.CommonImage) + return helpers.Command("run", "--data-root", data.Temp().Path(), "--rm", "-p", "22200-22299:22200-22299", testutil.CommonImage) }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - ExitCode: 1, - Errors: []error{errdefs.ErrInvalidArgument}, - Output: func(stdout string, info string, t *testing.T) { + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, t tig.T) { getAddrHash := func(addr string) string { const addrHashLen = 8 @@ -518,158 +570,103 @@ func TestSharedNetworkSetup(t *testing.T) { testCase := &test.Case{ Require: require.Not(require.Windows), Setup: func(data test.Data, helpers test.Helpers) { - data.Labels().Set("containerName1", data.Identifier("-container1")) - containerName1 := data.Labels().Get("containerName1") - helpers.Ensure("run", "-d", "--name", containerName1, - testutil.NginxAlpineImage) + data.Labels().Set("container1", data.Identifier("container1")) + helpers.Ensure("run", "-d", "--name", data.Identifier("container1"), + testutil.CommonImage, "sleep", "inf") + nerdtest.EnsureContainerStarted(helpers, data.Identifier("container1")) }, Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier("-container1")) + helpers.Anyhow("rm", "-f", data.Identifier("container1")) }, SubTests: []*test.Case{ { Description: "Test network is shared", NoParallel: true, // The validation involves starting of the main container: container1 Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rm", "-f", data.Identifier("container2")) }, - Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "-d", "--name", containerName2, - "--network=container:"+data.Labels().Get("containerName1"), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure( + "run", "-d", "--name", data.Identifier("container2"), + "--network=container:"+data.Labels().Get("container1"), testutil.NginxAlpineImage) - return cmd + data.Labels().Set("container2", data.Identifier("container2")) + nerdtest.EnsureContainerStarted(helpers, data.Identifier("container2")) }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - containerName2 := data.Identifier() - assert.Assert(t, strings.Contains(helpers.Capture("exec", containerName2, "wget", "-qO-", "http://127.0.0.1:80"), testutil.NginxAlpineIndexHTMLSnippet), info) - helpers.Ensure("restart", data.Labels().Get("containerName1")) - helpers.Ensure("stop", "--time=1", containerName2) - helpers.Ensure("start", containerName2) - assert.Assert(t, strings.Contains(helpers.Capture("exec", containerName2, "wget", "-qO-", "http://127.0.0.1:80"), testutil.NginxAlpineIndexHTMLSnippet), info) + SubTests: []*test.Case{ + { + NoParallel: true, + Description: "Test network is shared", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("container2"), "wget", "-qO-", "http://127.0.0.1:80") }, - } + Expected: test.Expects(0, nil, expect.Contains(testutil.NginxAlpineIndexHTMLSnippet)), + }, + { + NoParallel: true, + Description: "Test network is shared after restart", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("restart", data.Labels().Get("container1")) + helpers.Ensure("stop", "--time=1", data.Labels().Get("container2")) + helpers.Ensure("start", data.Labels().Get("container2")) + nerdtest.EnsureContainerStarted(helpers, data.Labels().Get("container2")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("container2"), "wget", "-qO-", "http://127.0.0.1:80") + + }, + Expected: test.Expects(0, nil, expect.Contains(testutil.NginxAlpineIndexHTMLSnippet)), + }, }, }, { Description: "Test uts is supported in shared network", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "-d", "--name", containerName2, "--uts", "host", - "--network=container:"+data.Labels().Get("containerName1"), - testutil.AlpineImage) - return cmd - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - return &test.Expected{ - ExitCode: 0, - } + return helpers.Command("run", "--rm", "--uts", "host", + "--network=container:"+data.Labels().Get("container1"), + testutil.CommonImage) }, + Expected: test.Expects(0, nil, nil), }, { Description: "Test dns is not supported", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "-d", "--name", containerName2, "--dns", "0.1.2.3", - "--network=container:"+data.Labels().Get("containerName1"), - testutil.AlpineImage) - return cmd - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - if nerdtest.IsDocker() { - return &test.Expected{ - ExitCode: 125, - } - - } - return &test.Expected{ - ExitCode: 1, - } + return helpers.Command("run", "--rm", "--dns", "0.1.2.3", + "--network=container:"+data.Labels().Get("container1"), + testutil.CommonImage) }, + // 1 for nerdctl, 125 for docker + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), }, { Description: "Test dns options is not supported", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "--name", containerName2, "--dns-option", "attempts:5", - "--network=container:"+data.Labels().Get("containerName1"), - testutil.AlpineImage, "cat", "/etc/resolv.conf") - return cmd - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - // The Option doesnt throw an error but is never inserted to the resolv.conf - return &test.Expected{ - ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, !strings.Contains(stdout, "attempts:5"), info) - }, - } + return helpers.Command("run", "--rm", "--dns-option", "attempts:5", + "--network=container:"+data.Labels().Get("container1"), + testutil.CommonImage, "cat", "/etc/resolv.conf") }, + // The Option doesn't throw an error but is never inserted to the resolv.conf + Expected: test.Expects(0, nil, expect.DoesNotContain("attempts:5")), }, { Description: "Test publish is not supported", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "-d", "--name", containerName2, "--publish", "80:8080", - "--network=container:"+data.Labels().Get("containerName1"), + return helpers.Command("run", "--rm", "--publish", "80:8080", + "--network=container:"+data.Labels().Get("container1"), testutil.AlpineImage) - return cmd - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - if nerdtest.IsDocker() { - return &test.Expected{ - ExitCode: 125, - } - - } - return &test.Expected{ - ExitCode: 1, - } }, + // 1 for nerdctl, 125 for docker + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), }, { Description: "Test hostname is not supported", - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - containerName2 := data.Identifier() - cmd := helpers.Command() - cmd.WithArgs("run", "-d", "--name", containerName2, "--hostname", "test", - "--network=container:"+data.Labels().Get("containerName1"), + return helpers.Command("run", "--rm", "--hostname", "test", + "--network=container:"+data.Labels().Get("container1"), testutil.AlpineImage) - return cmd - }, - Expected: func(data test.Data, helpers test.Helpers) *test.Expected { - if nerdtest.IsDocker() { - return &test.Expected{ - ExitCode: 125, - } - - } - return &test.Expected{ - ExitCode: 1, - } }, + // 1 for nerdctl, 125 for docker + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), }, }, } @@ -682,15 +679,15 @@ func TestSharedNetworkWithNone(t *testing.T) { Require: require.Not(require.Windows), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("run", "-d", "--name", data.Identifier("container1"), "--network", "none", - testutil.NginxAlpineImage) + testutil.CommonImage, "sleep", "inf") + nerdtest.EnsureContainerStarted(helpers, data.Identifier("container1")) }, Cleanup: func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rm", "-f", data.Identifier("container1")) - helpers.Anyhow("rm", "-f", data.Identifier("container2")) }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Command("run", "-d", "--name", data.Identifier("container2"), - "--network=container:"+data.Identifier("container1"), testutil.NginxAlpineImage) + return helpers.Command("run", "--rm", + "--network=container:"+data.Identifier("container1"), testutil.CommonImage) }, Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), } @@ -914,13 +911,14 @@ func TestNoneNetworkHostName(t *testing.T) { nerdtest.Setup() testCase := &test.Case{ Require: require.Not(require.Windows), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, Setup: func(data test.Data, helpers test.Helpers) { - output := helpers.Capture("run", "-d", "--name", data.Identifier(), "--network", "none", testutil.NginxAlpineImage) + output := helpers.Capture("run", "-d", "--name", data.Identifier(), "--network", "none", testutil.CommonImage, "sleep", "inf") assert.Assert(helpers.T(), len(output) > 12, output) data.Labels().Set("hostname", output[:12]) - }, - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) + nerdtest.EnsureContainerStarted(helpers, data.Identifier()) }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("exec", data.Identifier(), "cat", "/etc/hostname") @@ -939,79 +937,250 @@ func TestHostNetworkHostName(t *testing.T) { testCase := &test.Case{ Require: require.Not(require.Windows), Setup: func(data test.Data, helpers test.Helpers) { - data.Labels().Set("containerName1", data.Identifier()) - }, - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Custom("cat", "/etc/hostname").Run(&test.Expected{ + Output: func(stdout string, t tig.T) { + data.Labels().Set("hostHostname", stdout) + }, + }) }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Custom("cat", "/etc/hostname") + return helpers.Command("run", "--rm", + "--network", "host", + testutil.AlpineImage, "cat", "/etc/hostname") }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - hostname := stdout - assert.Assert(t, strings.Compare(strings.TrimSpace(helpers.Capture("run", "--name", data.Identifier(), "--network", "host", testutil.AlpineImage, "cat", "/etc/hostname")), strings.TrimSpace(hostname)) == 0, info) - }, + Output: expect.Equals(data.Labels().Get("hostHostname")), } }, } testCase.Run(t) } -func TestNoneNetworkDnsConfigs(t *testing.T) { +func TestHostNetworkDnsPreserved(t *testing.T) { nerdtest.Setup() testCase := &test.Case{ Require: require.Not(require.Windows), Setup: func(data test.Data, helpers test.Helpers) { - data.Labels().Set("containerName1", data.Identifier()) - }, - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) + // In some rootless CI job, slirp provides 10.0.2.3 as DNS server. + // We cannot simply parse host /etc/resolv.conf here. + helpers.Command("run", "--rm", + "-v", "/etc/resolv.conf:/mnt/resolv.conf:ro", + testutil.AlpineImage, + "grep", "-E", "^nameserver\\s+", "/mnt/resolv.conf").Run(&test.Expected{ + Output: func(stdout string, t tig.T) { + data.Labels().Set("nameservers", stdout) + }, + }) }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Command("run", "-d", "--name", data.Identifier(), "--network", "none", "--dns", "0.1.2.3", "--dns-search", "example.com", "--dns-option", "timeout:3", "--dns-option", "attempts:5", testutil.NginxAlpineImage) + return helpers.Command("run", "--rm", + "--network", "host", + testutil.AlpineImage, + "grep", "-E", "^nameserver\\s+", "/etc/resolv.conf") }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + // container with --network=host should have same nameserver as host + nameservers := data.Labels().Get("nameservers") return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - out := helpers.Capture("exec", data.Identifier(), "cat", "/etc/resolv.conf") - assert.Assert(t, strings.Contains(out, "0.1.2.3"), info) - assert.Assert(t, strings.Contains(out, "example.com"), info) - assert.Assert(t, strings.Contains(out, "attempts:5"), info) - assert.Assert(t, strings.Contains(out, "timeout:3"), info) - - }, + Output: expect.Equals(nameservers), } }, } testCase.Run(t) } -func TestHostNetworkDnsConfigs(t *testing.T) { +func TestDefaultNetworkDnsNoLocalhost(t *testing.T) { nerdtest.Setup() testCase := &test.Case{ Require: require.Not(require.Windows), - Setup: func(data test.Data, helpers test.Helpers) { - data.Labels().Set("containerName1", data.Identifier()) - }, - Cleanup: func(data test.Data, helpers test.Helpers) { - helpers.Anyhow("rm", "-f", data.Identifier()) - }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Command("run", "-d", "--name", data.Identifier(), "--network", "host", "--dns", "0.1.2.3", "--dns-search", "example.com", "--dns-option", "timeout:3", "--dns-option", "attempts:5", testutil.NginxAlpineImage) + return helpers.Command("run", "--rm", + testutil.AlpineImage, "grep", "-E", "^nameserver\\s+(127\\.|::1)", "/etc/resolv.conf") }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - out := helpers.Capture("exec", data.Identifier(), "cat", "/etc/resolv.conf") - assert.Assert(t, strings.Contains(out, "0.1.2.3"), info) - assert.Assert(t, strings.Contains(out, "example.com"), info) - assert.Assert(t, strings.Contains(out, "attempts:5"), info) - assert.Assert(t, strings.Contains(out, "timeout:3"), info) + ExitCode: 1, // no match + } + }, + } + testCase.Run(t) +} + +func TestNoneNetworkDnsConfigs(t *testing.T) { + nerdtest.Setup() + testCase := &test.Case{ + Require: require.Not(require.Windows), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--network", "none", + "--dns", "0.1.2.3", "--dns-search", "example.com", "--dns-option", "timeout:3", "--dns-option", "attempts:5", + testutil.CommonImage, "cat", "/etc/resolv.conf") + }, + Expected: test.Expects(0, nil, expect.Contains( + "0.1.2.3", + "example.com", + "attempts:5", + "timeout:3", + )), + } + testCase.Run(t) +} + +func TestHostNetworkDnsConfigs(t *testing.T) { + nerdtest.Setup() + testCase := &test.Case{ + Require: require.Not(require.Windows), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--network", "host", + "--dns", "0.1.2.3", "--dns-search", "example.com", "--dns-option", "timeout:3", "--dns-option", "attempts:5", + testutil.CommonImage, "cat", "/etc/resolv.conf") + }, + Expected: test.Expects(0, nil, expect.Contains( + "0.1.2.3", + "example.com", + "attempts:5", + "timeout:3", + )), + } + testCase.Run(t) +} + +func TestDNSWithGlobalConfig(t *testing.T) { + var configContent test.ConfigValue = `debug = false +debug_full = false +dns = ["10.10.10.10", "20.20.20.20"] +dns_opts = ["ndots:2", "timeout:5"] +dns_search = ["example.com", "test.local"]` + nerdtest.Setup() + + testCase := &test.Case{ + Config: test.WithConfig(nerdtest.NerdctlToml, configContent), + // NERDCTL_TOML not supported in Docker + Require: require.Not(nerdtest.Docker), + SubTests: []*test.Case{ + { + Description: "Global DNS settings are used when command line options are not provided", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + nerdctlTomlContent := string(helpers.Read(nerdtest.NerdctlToml)) + helpers.T().Log("NERDCTL_TOML file content:\n%s", nerdctlTomlContent) + cmd := helpers.Command("run", "--rm", testutil.CommonImage, "cat", "/etc/resolv.conf") + return cmd }, - } + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("nameserver 10.10.10.10"), + expect.Contains("nameserver 20.20.20.20"), + expect.Contains("search example.com test.local"), + expect.Contains("options ndots:2 timeout:5"), + )), + }, + { + Description: "Command line DNS options override global config", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + nerdctlTomlContent := string(helpers.Read(nerdtest.NerdctlToml)) + helpers.T().Log("NERDCTL_TOML file content:\n%s", nerdctlTomlContent) + cmd := helpers.Command("run", "--rm", + "--dns", "9.9.9.9", + "--dns-search", "override.com", + "--dns-opt", "ndots:3", + testutil.CommonImage, "cat", "/etc/resolv.conf") + return cmd + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("nameserver 9.9.9.9"), + expect.Contains("search override.com"), + expect.Contains("options ndots:3"), + )), + }, + { + Description: "Global DNS settings should also apply when using host network", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + nerdctlTomlContent := string(helpers.Read(nerdtest.NerdctlToml)) + helpers.T().Log("NERDCTL_TOML file content:\n%s", nerdctlTomlContent) + cmd := helpers.Command("run", "--rm", "--network", "host", + testutil.CommonImage, "cat", "/etc/resolv.conf") + return cmd + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("nameserver 10.10.10.10"), + expect.Contains("nameserver 20.20.20.20"), + expect.Contains("search example.com test.local"), + expect.Contains("options ndots:2 timeout:5"), + )), + }, + { + Description: "Global DNS settings should also apply when using none network", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + nerdctlTomlContent := string(helpers.Read(nerdtest.NerdctlToml)) + helpers.T().Log("NERDCTL_TOML file content:\n%s", nerdctlTomlContent) + cmd := helpers.Command("run", "--rm", "--network", "none", + testutil.CommonImage, "cat", "/etc/resolv.conf") + return cmd + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains("nameserver 10.10.10.10"), + expect.Contains("nameserver 20.20.20.20"), + expect.Contains("search example.com test.local"), + expect.Contains("options ndots:2 timeout:5"), + )), + }, + }, + } + testCase.Run(t) +} + +// TestReservePorts tests that a published port appears +// as a listening port on the host. +// See https://github.com/containerd/nerdctl/pull/4526 +func TestReservePorts(t *testing.T) { + nerdtest.Setup() + testCase := &test.Case{ + Require: require.All( + require.Not(require.Windows), + require.Not(nerdtest.RootlessWithoutDetachNetNS), // RootlessKit v1 + ), + NoParallel: true, + SubTests: []*test.Case{ + { + Description: "TCP", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("nginx"), + "-p", "60080:80", testutil.NginxAlpineImage) + nerdtest.EnsureContainerStarted(helpers, data.Identifier("nginx")) + time.Sleep(3 * time.Second) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("nginx")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--network=host", testutil.CommonImage, "netstat", "-lnt") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains(":60080"), + )), + }, + { + Description: "UDP", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("coredns"), + "-p", "60053:53/udp", testutil.CoreDNSImage) + nerdtest.EnsureContainerStarted(helpers, data.Identifier("coredns")) + time.Sleep(3 * time.Second) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("coredns")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--network=host", testutil.CommonImage, "netstat", "-lnu") + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, expect.All( + expect.Contains(":60053"), + )), + }, }, } testCase.Run(t) diff --git a/cmd/nerdctl/container/container_run_restart_linux_test.go b/cmd/nerdctl/container/container_run_restart_linux_test.go index a5ce810bbad..795550696f6 100644 --- a/cmd/nerdctl/container/container_run_restart_linux_test.go +++ b/cmd/nerdctl/container/container_run_restart_linux_test.go @@ -69,7 +69,7 @@ func TestRunRestart(t *testing.T) { } return nil } - assert.NilError(t, check(30)) + assert.NilError(t, check(5)) base.KillDaemon() base.EnsureDaemonActive() diff --git a/cmd/nerdctl/container/container_run_runtime_linux_test.go b/cmd/nerdctl/container/container_run_runtime_linux_test.go index ea7473f2d20..2d42734ec8a 100644 --- a/cmd/nerdctl/container/container_run_runtime_linux_test.go +++ b/cmd/nerdctl/container/container_run_runtime_linux_test.go @@ -27,3 +27,31 @@ func TestRunSysctl(t *testing.T) { base := testutil.NewBase(t) base.Cmd("run", "--rm", "--sysctl", "net.ipv4.ip_forward=1", testutil.AlpineImage, "cat", "/proc/sys/net/ipv4/ip_forward").AssertOutExactly("1\n") } + +func TestRunSysctl_DefaultUnprivilegedPortStart(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + // No --sysctl flags, default network mode (non-host). + // We expect net.ipv4.ip_unprivileged_port_start=0 inside the container, + // because withDefaultUnprivilegedPortSysctl should apply the default. + base.Cmd( + "run", "--rm", + testutil.AlpineImage, + "cat", "/proc/sys/net/ipv4/ip_unprivileged_port_start", + ).AssertOutExactly("0\n") +} + +func TestRunSysctl_UnprivilegedPortStartOverride(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + // User explicitly sets net.ipv4.ip_unprivileged_port_start=1000. + // We must NOT override this; the container should see "1000". + base.Cmd( + "run", "--rm", + "--sysctl", "net.ipv4.ip_unprivileged_port_start=1000", + testutil.AlpineImage, + "cat", "/proc/sys/net/ipv4/ip_unprivileged_port_start", + ).AssertOutExactly("1000\n") +} diff --git a/cmd/nerdctl/container/container_run_soci_linux_test.go b/cmd/nerdctl/container/container_run_soci_linux_test.go index 670a15dc7de..111db5dddc5 100644 --- a/cmd/nerdctl/container/container_run_soci_linux_test.go +++ b/cmd/nerdctl/container/container_run_soci_linux_test.go @@ -25,6 +25,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -35,6 +36,7 @@ func TestRunSoci(t *testing.T) { testCase.Require = require.All( require.Not(nerdtest.Docker), + require.Amd64, nerdtest.Soci, ) @@ -44,7 +46,7 @@ func TestRunSoci(t *testing.T) { testCase.Setup = func(data test.Data, helpers test.Helpers) { helpers.Custom("mount").Run(&test.Expected{ ExitCode: 0, - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { data.Labels().Set("beforeCount", strconv.Itoa(strings.Count(stdout, "fuse.rawBridge"))) }, }) @@ -60,12 +62,12 @@ func TestRunSoci(t *testing.T) { testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var afterCount int beforeCount, _ := strconv.Atoi(data.Labels().Get("beforeCount")) helpers.Custom("mount").Run(&test.Expected{ - Output: func(stdout, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { afterCount = strings.Count(stdout, "fuse.rawBridge") }, }) diff --git a/cmd/nerdctl/container/container_run_test.go b/cmd/nerdctl/container/container_run_test.go index 345523f1382..ea424a2c889 100644 --- a/cmd/nerdctl/container/container_run_test.go +++ b/cmd/nerdctl/container/container_run_test.go @@ -37,8 +37,10 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -156,7 +158,7 @@ func TestRunExitCode(t *testing.T) { Output: expect.All( expect.Match(regexp.MustCompile("Exited [(]123[)][A-Za-z0-9 ]+"+data.Identifier("exit123"))), expect.Match(regexp.MustCompile("Exited [(]0[)][A-Za-z0-9 ]+"+data.Identifier("exit0"))), - func(stdout, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Equal(t, nerdtest.InspectContainer(helpers, data.Identifier("exit0")).State.Status, "exited") assert.Equal(t, nerdtest.InspectContainer(helpers, data.Identifier("exit123")).State.Status, "exited") }, @@ -784,7 +786,7 @@ func TestRunFromOCIArchive(t *testing.T) { tarPath := fmt.Sprintf("%s/%s.tar", buildCtx, imageName) base.Cmd("build", "--tag", tag, fmt.Sprintf("--output=type=oci,dest=%s", tarPath), buildCtx).AssertOK() - base.Cmd("run", "--rm", fmt.Sprintf("oci-archive://%s", tarPath)).AssertOutContainsAll(fmt.Sprintf("Loaded image: %s", tag), sentinel) + base.Cmd("run", "--rm", fmt.Sprintf("oci-archive://%s", tarPath)).AssertOutContainsAll(tag, sentinel) } func TestRunDomainname(t *testing.T) { @@ -837,3 +839,273 @@ func TestRunDomainname(t *testing.T) { }) } } + +func TestRunHealthcheckFlags(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("healthcheck tests are skipped in rootless environment") + } + testCase := nerdtest.Setup() + + testCases := []struct { + name string + args []string + shouldFail bool + expectTest []string + expectRetries int + expectInterval time.Duration + expectTimeout time.Duration + expectStartPeriod time.Duration + }{ + { + name: "Valid_full_config", + args: []string{ + "--health-cmd", "curl -f http://localhost || exit 1", + "--health-interval", "30s", + "--health-timeout", "5s", + "--health-retries", "3", + "--health-start-period", "2s", + }, + expectTest: []string{"CMD-SHELL", "curl -f http://localhost || exit 1"}, + expectInterval: 30 * time.Second, + expectTimeout: 5 * time.Second, + expectRetries: 3, + expectStartPeriod: 2 * time.Second, + }, + { + name: "No_healthcheck", + args: []string{ + "--no-healthcheck", + }, + expectTest: []string{"NONE"}, + }, + { + name: "No_healthcheck_flag", + args: []string{}, + expectTest: nil, + }, + { + name: "Conflicting_flags", + args: []string{ + "--no-healthcheck", "--health-cmd", "true", + }, + shouldFail: true, + }, + { + name: "Negative_retries", + args: []string{ + "--health-cmd", "true", + "--health-retries", "-2", + }, + shouldFail: true, + }, + { + name: "Negative_timeout", + args: []string{ + "--health-cmd", "true", + "--health-timeout", "-5s", + }, + shouldFail: true, + }, + { + name: "Invalid_timeout_format", + args: []string{ + "--health-cmd", "true", + "--health-timeout", "5blah", + }, + shouldFail: true, + }, + { + name: "Health_cmd_cmd_shell", + args: []string{ + "--health-cmd", "curl -f http://localhost || exit 1", + }, + expectTest: []string{"CMD-SHELL", "curl -f http://localhost || exit 1"}, + }, + { + name: "Health_cmd_array_like", + args: []string{ + "--health-cmd", "echo hello", + }, + expectTest: []string{"CMD-SHELL", "echo hello"}, + }, + { + name: "Health_cmd_empty", + args: []string{ + "--health-cmd", "", + "--health-retries", "2", + }, + expectTest: nil, + expectRetries: 2, + }, + } + + for _, tc := range testCases { + tc := tc + + testCase.SubTests = append(testCase.SubTests, &test.Case{ + Description: tc.name, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + args := append([]string{"run", "-d", "--name", tc.name}, tc.args...) + args = append(args, testutil.CommonImage, "sleep", "infinity") + return helpers.Command(args...) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + if tc.shouldFail { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + } + } + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, tc.name) + hc := inspect.Config.Healthcheck + if tc.expectTest == nil { + assert.Assert(t, hc == nil || len(hc.Test) == 0) + } else { + assert.Assert(t, hc != nil) + assert.DeepEqual(t, hc.Test, tc.expectTest) + } + if tc.expectRetries > 0 { + assert.Equal(t, hc.Retries, tc.expectRetries) + } + if tc.expectTimeout > 0 { + assert.Equal(t, hc.Timeout, tc.expectTimeout) + } + if tc.expectInterval > 0 { + assert.Equal(t, hc.Interval, tc.expectInterval) + } + if tc.expectStartPeriod > 0 { + assert.Equal(t, hc.StartPeriod, tc.expectStartPeriod) + } + }, + ), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", tc.name) + }, + }) + } + + testCase.Run(t) +} + +func TestRunHealthcheckFromImage(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("healthcheck tests are skipped in rootless environment") + } + nerdtest.Setup() + + dockerfile := fmt.Sprintf(`FROM %s +HEALTHCHECK --interval=30s --timeout=10s CMD wget -q --spider http://localhost:8080 || exit 1 + `, testutil.CommonImage) + + testCase := &test.Case{ + Require: nerdtest.Build, + Setup: func(data test.Data, helpers test.Helpers) { + data.Temp().Save(dockerfile, "Dockerfile") + data.Labels().Set("image", data.Identifier()) + helpers.Ensure("build", "-t", data.Labels().Get("image"), data.Temp().Path()) + }, + SubTests: []*test.Case{ + { + Description: "merge_with_image", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "-d", "--name", data.Identifier(), + "--health-retries=5", + "--health-interval=45s", + data.Labels().Get("image")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + hc := inspect.Config.Healthcheck + assert.Assert(t, hc != nil, "expected healthcheck config to be present") + assert.DeepEqual(t, hc.Test, []string{"CMD-SHELL", "wget -q --spider http://localhost:8080 || exit 1"}) + assert.Equal(t, 5, hc.Retries) // From CLI flags + assert.Equal(t, 45*time.Second, hc.Interval) // From CLI flags + assert.Equal(t, 10*time.Second, hc.Timeout) // From Dockerfile + }), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + }, + { + Description: "Disable image health checks via runtime flag", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command( + "run", "-d", "--name", data.Identifier(), + "--no-healthcheck", + data.Labels().Get("image"), + ) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All(func(stdout string, t tig.T) { + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + hc := inspect.Config.Healthcheck + assert.Assert(t, hc != nil, "expected healthcheck config to be present") + assert.DeepEqual(t, hc.Test, []string{"NONE"}) + }), + } + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + }, + }, + } + + testCase.Run(t) +} + +func countFIFOFiles(root string) (int, error) { + count := 0 + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode()&os.ModeNamedPipe != 0 { + count++ + } + return nil + }) + return count, err +} +func TestCleanupFIFOs(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("/run/containerd/fifo/ doesn't exist on rootless") + } + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + testutil.DockerIncompatible(t) + testCase := nerdtest.Setup() + testCase.NoParallel = true + testCase.Setup = func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("run", "-it", "--rm", testutil.CommonImage, "date") + cmd.WithPseudoTTY() + cmd.Run(&test.Expected{ + ExitCode: 0, + }) + oldNumFifos, err := countFIFOFiles("/run/containerd/fifo/") + assert.NilError(t, err) + + cmd = helpers.Command("run", "-it", "--rm", testutil.CommonImage, "date") + cmd.WithPseudoTTY() + cmd.Run(&test.Expected{ + ExitCode: 0, + }) + newNumFifos, err := countFIFOFiles("/run/containerd/fifo/") + assert.NilError(t, err) + assert.Equal(t, oldNumFifos, newNumFifos) + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_run_user_linux_test.go b/cmd/nerdctl/container/container_run_user_linux_test.go index 61e2d674d77..f9fcf374c76 100644 --- a/cmd/nerdctl/container/container_run_user_linux_test.go +++ b/cmd/nerdctl/container/container_run_user_linux_test.go @@ -24,6 +24,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -222,12 +223,13 @@ func TestUsernsMappingRunCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) if err != nil { - t.Fatalf("Failed to get container host UID: %v", err) + t.Log(fmt.Sprintf("Failed to get container host UID: %v", err)) + t.FailNow() } - assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID")) }, } }, @@ -249,12 +251,13 @@ func TestUsernsMappingRunCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) if err != nil { - t.Fatalf("Failed to get container host UID: %v", err) + t.Log(fmt.Sprintf("Failed to get container host UID: %v", err)) + t.FailNow() } - assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID")) }, } }, @@ -295,12 +298,13 @@ func TestUsernsMappingRunCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) if err != nil { - t.Fatalf("Failed to get container host UID: %v", err) + t.Log(fmt.Sprintf("Failed to get container host UID: %v", err)) + t.FailNow() } - assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID")) }, } }, @@ -322,12 +326,13 @@ func TestUsernsMappingRunCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) if err != nil { - t.Fatalf("Failed to get container host UID: %v", err) + t.Log(fmt.Sprintf("Failed to get container host UID: %v", err)) + t.FailNow() } - assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID"), info) + assert.Assert(t, actualHostUID == data.Labels().Get("expectedHostUID")) }, } }, @@ -367,12 +372,13 @@ func TestUsernsMappingRunCmd(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { actualHostUID, err := getContainerHostUID(helpers, data.Identifier()) if err != nil { - t.Fatalf("Failed to get container host UID: %v", err) + t.Log(fmt.Sprintf("Failed to get container host UID: %v", err)) + t.FailNow() } - assert.Assert(t, actualHostUID == "0", info) + assert.Assert(t, actualHostUID == "0") }, } }, diff --git a/cmd/nerdctl/container/container_start.go b/cmd/nerdctl/container/container_start.go index 7b770d9b1e6..089a871d515 100644 --- a/cmd/nerdctl/container/container_start.go +++ b/cmd/nerdctl/container/container_start.go @@ -44,6 +44,8 @@ func StartCommand() *cobra.Command { cmd.Flags().BoolP("attach", "a", false, "Attach STDOUT/STDERR and forward signals") cmd.Flags().String("detach-keys", consoleutil.DefaultDetachKeys, "Override the default detach keys") cmd.Flags().BoolP("interactive", "i", false, "Attach container's STDIN") + cmd.Flags().String("checkpoint", "", "checkpoint name") + cmd.Flags().String("checkpoint-dir", "", "checkpoint directory") return cmd } @@ -64,12 +66,22 @@ func startOptions(cmd *cobra.Command) (types.ContainerStartOptions, error) { if err != nil { return types.ContainerStartOptions{}, err } + checkpoint, err := cmd.Flags().GetString("checkpoint") + if err != nil { + return types.ContainerStartOptions{}, err + } + checkpointDir, err := cmd.Flags().GetString("checkpoint-dir") + if err != nil { + return types.ContainerStartOptions{}, err + } return types.ContainerStartOptions{ - Stdout: cmd.OutOrStdout(), - GOptions: globalOptions, - Attach: attach, - DetachKeys: detachKeys, - Interactive: interactive, + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Attach: attach, + DetachKeys: detachKeys, + Interactive: interactive, + Checkpoint: checkpoint, + CheckpointDir: checkpointDir, }, nil } @@ -79,6 +91,8 @@ func startAction(cmd *cobra.Command, args []string) error { return err } + options.NerdctlCmd, options.NerdctlArgs = helpers.GlobalFlags(cmd) + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address) if err != nil { return err diff --git a/cmd/nerdctl/container/container_start_linux_test.go b/cmd/nerdctl/container/container_start_linux_test.go index 6d9ca8c313b..1a86c3026b2 100644 --- a/cmd/nerdctl/container/container_start_linux_test.go +++ b/cmd/nerdctl/container/container_start_linux_test.go @@ -20,13 +20,17 @@ import ( "bytes" "errors" "io" + "strconv" "strings" "testing" + "time" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -67,7 +71,7 @@ func TestStartDetachKeys(t *testing.T) { ExitCode: 0, Errors: []error{errors.New("detach keys")}, Output: expect.All( - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("inspect", "--format", "json", data.Identifier()), "\"Running\":true")) }, ), @@ -76,3 +80,54 @@ func TestStartDetachKeys(t *testing.T) { testCase.Run(t) } + +func TestStartWithCheckpoint(t *testing.T) { + + testCase := nerdtest.Setup() + testCase.Require = require.All( + require.Not(nerdtest.Rootless), + // Docker version 28.x has a known regression that breaks Checkpoint/Restore functionality. + // The issue is tracked in the moby/moby project as https://github.com/moby/moby/issues/50750. + require.Not(nerdtest.Docker), + ) + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // Use an in-memory tmpfs to model in-memory state without introducing extra processes + // Single PID 1 shell: continuously increment a counter and write to /state/counter (tmpfs) + helpers.Ensure("run", "-d", "--name", data.Identifier(), "--tmpfs", "/state", testutil.CommonImage, + "sh", "-c", `i=0; while true; do i=$((i+1)); printf "%d\n" "$i" >/state/counter; sleep 0.2; done`) + // Give some time for the counter to increase before checkpoint to validate continuity after restore + time.Sleep(1 * time.Second) + helpers.Ensure("checkpoint", "create", data.Identifier(), data.Identifier()+"-checkpoint") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("start", "--checkpoint", data.Identifier()+"-checkpoint", data.Identifier()) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + func(_ string, t tig.T) { + // Validate in-memory state continuity via tmpfs: counter should not reset and must keep increasing + // Short delay to allow the container to resume; if the counter had reset to 0, it could not reach >5 this fast + time.Sleep(200 * time.Millisecond) + c1Str := strings.TrimSpace(helpers.Capture("exec", data.Identifier(), "cat", "/state/counter")) + var parseErrs []error + c1, err1 := strconv.Atoi(c1Str) + if err1 != nil { + parseErrs = append(parseErrs, err1) + } + assert.Assert(t, len(parseErrs) == 0, "failed to parse counter values: %v", parseErrs) + assert.Assert(t, c1 > 5, "tmpfs in-memory counter seems reset or too small: %d", c1) + }, + ), + } + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_start_test.go b/cmd/nerdctl/container/container_start_test.go index 60369433d24..d3898bf9011 100644 --- a/cmd/nerdctl/container/container_start_test.go +++ b/cmd/nerdctl/container/container_start_test.go @@ -17,31 +17,58 @@ package container import ( - "runtime" "testing" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestStart(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) + nerdtest.Setup() - defer base.Cmd("rm", "-f", containerName).AssertOK() - base.Cmd("run", "--name", containerName, testutil.CommonImage).AssertOK() - base.Cmd("start", containerName).AssertOutContains(containerName) + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", + "--name", data.Identifier(), + testutil.CommonImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("start", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return test.Expects(0, nil, expect.Contains(data.Identifier()))(data, helpers) + }, + } + testCase.Run(t) } func TestStartAttach(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("start attach test is not yet implemented on Windows") - } - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - defer base.Cmd("rm", "-f", containerName).AssertOK() - base.Cmd("run", "--name", containerName, testutil.CommonImage, "sh", "-euxc", "echo foo").AssertOK() - base.Cmd("start", "-a", containerName).AssertOutContains("foo") + nerdtest.Setup() + + testCase := &test.Case{ + Require: require.Not(require.Windows), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", + "--name", data.Identifier(), + testutil.CommonImage, "sh", "-euxc", "echo foo") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("start", "-a", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return test.Expects(0, nil, expect.Contains("foo"))(data, helpers) + }, + } + testCase.Run(t) } diff --git a/cmd/nerdctl/container/container_stop_linux_test.go b/cmd/nerdctl/container/container_stop_linux_test.go index 135da90a938..e2f581a1ca8 100644 --- a/cmd/nerdctl/container/container_stop_linux_test.go +++ b/cmd/nerdctl/container/container_stop_linux_test.go @@ -66,14 +66,14 @@ func TestStopStart(t *testing.T) { return nil } - assert.NilError(t, check(30)) + assert.NilError(t, check(5)) base.Cmd("stop", testContainerName).AssertOK() base.Cmd("exec", testContainerName, "ps").AssertFail() if check(1) == nil { t.Fatal("expected to get an error") } base.Cmd("start", testContainerName).AssertOK() - assert.NilError(t, check(30)) + assert.NilError(t, check(5)) } func TestStopWithStopSignal(t *testing.T) { @@ -199,3 +199,23 @@ func TestStopWithTimeout(t *testing.T) { // The container should get the SIGKILL before the 10s default timeout assert.Assert(t, elapsed < 10*time.Second, "Container did not respect --timeout flag") } +func TestStopCleanupFIFOs(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("/run/containerd/fifo/ doesn't exist on rootless") + } + testutil.DockerIncompatible(t) + base := testutil.NewBase(t) + testContainerName := testutil.Identifier(t) + oldNumFifos, err := countFIFOFiles("/run/containerd/fifo/") + assert.NilError(t, err) + // Stop the container after 2 seconds + go func() { + time.Sleep(2 * time.Second) + base.Cmd("stop", testContainerName).AssertOK() + newNumFifos, err := countFIFOFiles("/run/containerd/fifo/") + assert.NilError(t, err) + assert.Equal(t, oldNumFifos, newNumFifos) + }() + // Start a container that is automatically removed after it exits + base.Cmd("run", "--rm", "--name", testContainerName, testutil.NginxAlpineImage).AssertOK() +} diff --git a/cmd/nerdctl/container/container_unpause.go b/cmd/nerdctl/container/container_unpause.go index 24e0b43e737..cb5a9b3cb44 100644 --- a/cmd/nerdctl/container/container_unpause.go +++ b/cmd/nerdctl/container/container_unpause.go @@ -46,9 +46,12 @@ func unpauseOptions(cmd *cobra.Command) (types.ContainerUnpauseOptions, error) { if err != nil { return types.ContainerUnpauseOptions{}, err } + nerdctlCmd, nerdctlArgs := helpers.GlobalFlags(cmd) return types.ContainerUnpauseOptions{ - GOptions: globalOptions, - Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Stdout: cmd.OutOrStdout(), + NerdctlCmd: nerdctlCmd, + NerdctlArgs: nerdctlArgs, }, nil } diff --git a/cmd/nerdctl/container/container_update.go b/cmd/nerdctl/container/container_update.go index 28ac0f6d078..51240b87d24 100644 --- a/cmd/nerdctl/container/container_update.go +++ b/cmd/nerdctl/container/container_update.go @@ -333,8 +333,8 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, if spec.Linux.Resources.Pids == nil { spec.Linux.Resources.Pids = &runtimespec.LinuxPids{} } - if spec.Linux.Resources.Pids.Limit != opts.PidsLimit { - spec.Linux.Resources.Pids.Limit = opts.PidsLimit + if spec.Linux.Resources.Pids.Limit == nil || (spec.Linux.Resources.Pids.Limit != nil && *spec.Linux.Resources.Pids.Limit != opts.PidsLimit) { + spec.Linux.Resources.Pids.Limit = &opts.PidsLimit } } } diff --git a/cmd/nerdctl/container/multi_platform_linux_test.go b/cmd/nerdctl/container/multi_platform_linux_test.go index eeb7c9f9004..01ae208528c 100644 --- a/cmd/nerdctl/container/multi_platform_linux_test.go +++ b/cmd/nerdctl/container/multi_platform_linux_test.go @@ -163,7 +163,7 @@ RUN uname -m > /usr/share/nginx/html/index.html } for testURL, expectedIndexHTML := range testCases { - resp, err := nettestutil.HTTPGet(testURL, 50, false) + resp, err := nettestutil.HTTPGet(testURL, 5, false) assert.NilError(t, err) respBody, err := io.ReadAll(resp.Body) assert.NilError(t, err) diff --git a/cmd/nerdctl/helpers/cobra.go b/cmd/nerdctl/helpers/cobra.go index d35030ea8cf..8ee5baeb260 100644 --- a/cmd/nerdctl/helpers/cobra.go +++ b/cmd/nerdctl/helpers/cobra.go @@ -156,7 +156,11 @@ func GlobalFlags(cmd *cobra.Command) (string, []string) { flagSet.VisitAll(func(f *pflag.Flag) { key := f.Name val := f.Value.String() - if f.Changed { + // Include flag if: + // 1. It was explicitly changed via CLI (highest priority), OR + // 2. It has a non-default value (from TOML config) + // This ensures both CLI flags and TOML config values are propagated + if f.Changed || (val != f.DefValue && val != "") { args = append(args, "--"+key+"="+val) } }) @@ -283,3 +287,10 @@ func AddPersistentBoolFlag(cmd *cobra.Command, name string, aliases, nonPersiste } } } + +// HiddenPersistentStringArrayFlag creates a persistent string slice flag and hides it. +// Used mainly to pass global config values to individual commands. +func HiddenPersistentStringArrayFlag(cmd *cobra.Command, name string, value []string, usage string) { + cmd.PersistentFlags().StringSlice(name, value, usage) + cmd.PersistentFlags().MarkHidden(name) +} diff --git a/cmd/nerdctl/helpers/flagutil.go b/cmd/nerdctl/helpers/flagutil.go index 4f9261c0cf3..22fc1acb1bf 100644 --- a/cmd/nerdctl/helpers/flagutil.go +++ b/cmd/nerdctl/helpers/flagutil.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/fs" ) func VerifyOptions(cmd *cobra.Command) (opt types.ImageVerifyOptions, err error) { @@ -46,6 +47,35 @@ func VerifyOptions(cmd *cobra.Command) (opt types.ImageVerifyOptions, err error) return } +func ValidateHealthcheckFlags(options types.ContainerCreateOptions) error { + healthFlagsSet := + options.HealthInterval != 0 || + options.HealthTimeout != 0 || + options.HealthRetries != 0 || + options.HealthStartPeriod != 0 + + if options.NoHealthcheck { + if options.HealthCmd != "" || healthFlagsSet { + return fmt.Errorf("--no-healthcheck conflicts with --health-* options") + } + } + + // Note: HealthCmd can be empty with other healthcheck flags set cause healthCmd could be coming from image. + if options.HealthInterval < 0 { + return fmt.Errorf("--health-interval cannot be negative") + } + if options.HealthTimeout < 0 { + return fmt.Errorf("--health-timeout cannot be negative") + } + if options.HealthRetries < 0 { + return fmt.Errorf("--health-retries cannot be negative") + } + if options.HealthStartPeriod < 0 { + return fmt.Errorf("--health-start-period cannot be negative") + } + return nil +} + func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) { debug, err := cmd.Flags().GetBool("debug") if err != nil { @@ -111,6 +141,24 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) if err != nil { return types.GlobalCommandOptions{}, err } + dns, err := cmd.Flags().GetStringSlice("global-dns") + if err != nil { + return types.GlobalCommandOptions{}, err + } + dnsOpts, err := cmd.Flags().GetStringSlice("global-dns-opts") + if err != nil { + return types.GlobalCommandOptions{}, err + } + dnsSearch, err := cmd.Flags().GetStringSlice("global-dns-search") + if err != nil { + return types.GlobalCommandOptions{}, err + } + + // Point to dataRoot for filesystem-helpers implementing rollback / backups. + err = fs.InitFS(dataRoot) + if err != nil { + return types.GlobalCommandOptions{}, err + } return types.GlobalCommandOptions{ Debug: debug, @@ -129,6 +177,9 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) BridgeIP: bridgeIP, KubeHideDupe: kubeHideDupe, CDISpecDirs: cdiSpecDirs, + DNS: dns, + DNSOpts: dnsOpts, + DNSSearch: dnsSearch, }, nil } diff --git a/cmd/nerdctl/helpers/testing_linux.go b/cmd/nerdctl/helpers/testing_linux.go index bf63686f0c8..60a4cd76beb 100644 --- a/cmd/nerdctl/helpers/testing_linux.go +++ b/cmd/nerdctl/helpers/testing_linux.go @@ -74,7 +74,7 @@ func ComposeUp(t *testing.T, base *testutil.Base, dockerComposeYAML string, opts base.Cmd("network", "inspect", fmt.Sprintf("%s_default", projectName)).AssertOK() checkWordpress := func() error { - resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 10, false) + resp, err := nettestutil.HTTPGet("http://127.0.0.1:8080", 5, false) if err != nil { return err } diff --git a/cmd/nerdctl/image/image.go b/cmd/nerdctl/image/image.go index 47db856f069..a711e392797 100644 --- a/cmd/nerdctl/image/image.go +++ b/cmd/nerdctl/image/image.go @@ -41,6 +41,7 @@ func Command() *cobra.Command { PushCommand(), LoadCommand(), SaveCommand(), + ImportCommand(), TagCommand(), imageRemoveCommand(), convertCommand(), diff --git a/cmd/nerdctl/image/image_convert.go b/cmd/nerdctl/image/image_convert.go index 871d9c97d81..ebe86119e31 100644 --- a/cmd/nerdctl/image/image_convert.go +++ b/cmd/nerdctl/image/image_convert.go @@ -61,6 +61,7 @@ func convertCommand() *cobra.Command { cmd.Flags().Int("estargz-min-chunk-size", 0, "The minimal number of bytes of data must be written in one gzip stream. (requires stargz-snapshotter >= v0.13.0)") cmd.Flags().Bool("estargz-external-toc", false, "Separate TOC JSON into another image (called \"TOC image\"). The name of TOC image is the original + \"-esgztoc\" suffix. Both eStargz and the TOC image should be pushed to the same registry. (requires stargz-snapshotter >= v0.13.0) (EXPERIMENTAL)") cmd.Flags().Bool("estargz-keep-diff-id", false, "Convert to esgz without changing diffID (cannot be used in conjunction with '--estargz-record-in'. must be specified with '--estargz-external-toc')") + cmd.Flags().String("estargz-gzip-helper", "", "Helper command for decompressing layers compressed with gzip. Options: pigz, igzip, or gzip.") // #endregion // #region zstd flags @@ -89,6 +90,12 @@ func convertCommand() *cobra.Command { cmd.Flags().String("overlaybd-dbstr", "", "Database config string for overlaybd") // #endregion + // #region soci flags + cmd.Flags().Bool("soci", false, "Convert image to SOCI Index V2 format.") + cmd.Flags().Int64("soci-min-layer-size", -1, "The minimum size of layers that will be converted to SOCI Index V2 format") + cmd.Flags().Int64("soci-span-size", -1, "The size of SOCI spans") + // #endregion + // #region generic flags cmd.Flags().Bool("uncompress", false, "Convert tar.gz layers to uncompressed tar layers") cmd.Flags().Bool("oci", false, "Convert Docker media types to OCI media types") @@ -143,6 +150,10 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { if err != nil { return types.ImageConvertOptions{}, err } + estargzGzipHelper, err := cmd.Flags().GetString("estargz-gzip-helper") + if err != nil { + return types.ImageConvertOptions{}, err + } // #endregion // #region zstd flags @@ -213,6 +224,21 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { } // #endregion + // #region soci flags + soci, err := cmd.Flags().GetBool("soci") + if err != nil { + return types.ImageConvertOptions{}, err + } + sociMinLayerSize, err := cmd.Flags().GetInt64("soci-min-layer-size") + if err != nil { + return types.ImageConvertOptions{}, err + } + sociSpanSize, err := cmd.Flags().GetInt64("soci-span-size") + if err != nil { + return types.ImageConvertOptions{}, err + } + // #endregion + // #region generic flags uncompress, err := cmd.Flags().GetBool("uncompress") if err != nil { @@ -237,37 +263,6 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { return types.ImageConvertOptions{ GOptions: globalOptions, Format: format, - // #region estargz flags - Estargz: estargz, - EstargzRecordIn: estargzRecordIn, - EstargzCompressionLevel: estargzCompressionLevel, - EstargzChunkSize: estargzChunkSize, - EstargzMinChunkSize: estargzMinChunkSize, - EstargzExternalToc: estargzExternalTOC, - EstargzKeepDiffID: estargzKeepDiffID, - // #endregion - // #region zstd flags - Zstd: zstd, - ZstdCompressionLevel: zstdCompressionLevel, - // #endregion - // #region zstd:chunked flags - ZstdChunked: zstdchunked, - ZstdChunkedCompressionLevel: zstdChunkedCompressionLevel, - ZstdChunkedChunkSize: zstdChunkedChunkSize, - ZstdChunkedRecordIn: zstdChunkedRecordIn, - // #endregion - // #region nydus flags - Nydus: nydus, - NydusBuilderPath: nydusBuilderPath, - NydusWorkDir: nydusWorkDir, - NydusPrefetchPatterns: nydusPrefetchPatterns, - NydusCompressor: nydusCompressor, - // #endregion - // #region overlaybd flags - Overlaybd: overlaybd, - OverlayFsType: overlaybdFsType, - OverlaydbDBStr: overlaybdDbstr, - // #endregion // #region generic flags Uncompress: uncompress, Oci: oci, @@ -276,6 +271,48 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { Platforms: platforms, AllPlatforms: allPlatforms, // #endregion + // Embed image format options + EstargzOptions: types.EstargzOptions{ + Estargz: estargz, + EstargzRecordIn: estargzRecordIn, + EstargzCompressionLevel: estargzCompressionLevel, + EstargzChunkSize: estargzChunkSize, + EstargzMinChunkSize: estargzMinChunkSize, + EstargzExternalToc: estargzExternalTOC, + EstargzKeepDiffID: estargzKeepDiffID, + EstargzGzipHelper: estargzGzipHelper, + }, + ZstdOptions: types.ZstdOptions{ + Zstd: zstd, + ZstdCompressionLevel: zstdCompressionLevel, + }, + ZstdChunkedOptions: types.ZstdChunkedOptions{ + ZstdChunked: zstdchunked, + ZstdChunkedCompressionLevel: zstdChunkedCompressionLevel, + ZstdChunkedChunkSize: zstdChunkedChunkSize, + ZstdChunkedRecordIn: zstdChunkedRecordIn, + }, + NydusOptions: types.NydusOptions{ + Nydus: nydus, + NydusBuilderPath: nydusBuilderPath, + NydusWorkDir: nydusWorkDir, + NydusPrefetchPatterns: nydusPrefetchPatterns, + NydusCompressor: nydusCompressor, + }, + OverlaybdOptions: types.OverlaybdOptions{ + Overlaybd: overlaybd, + OverlayFsType: overlaybdFsType, + OverlaydbDBStr: overlaybdDbstr, + }, + SociConvertOptions: types.SociConvertOptions{ + Soci: soci, + SociOptions: types.SociOptions{ + SpanSize: sociSpanSize, + MinLayerSize: sociMinLayerSize, + Platforms: platforms, + AllPlatforms: allPlatforms, + }, + }, Stdout: cmd.OutOrStdout(), }, nil } diff --git a/cmd/nerdctl/image/image_convert_linux_test.go b/cmd/nerdctl/image/image_convert_linux_test.go index b26358ec8b9..a13fc08d595 100644 --- a/cmd/nerdctl/image/image_convert_linux_test.go +++ b/cmd/nerdctl/image/image_convert_linux_test.go @@ -19,13 +19,14 @@ package image import ( "fmt" "testing" + "time" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" - "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" ) func TestImageConvert(t *testing.T) { @@ -37,8 +38,9 @@ func TestImageConvert(t *testing.T) { require.Not(require.Windows), require.Not(nerdtest.Docker), ), + NoParallel: true, Setup: func(data test.Data, helpers test.Helpers) { - helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("pull", "--quiet", "--all-platforms", testutil.CommonImage) }, SubTests: []*test.Case{ { @@ -88,6 +90,42 @@ func TestImageConvert(t *testing.T) { }, Expected: test.Expects(0, nil, nil), }, + { + Description: "soci", + Require: require.All( + require.Not(nerdtest.Docker), + nerdtest.Soci, + nerdtest.SociVersion("0.10.0"), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "convert", "--soci", + "--soci-span-size", "2097152", + "--soci-min-layer-size", "0", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "soci with all-platforms", + Require: require.All( + require.Not(nerdtest.Docker), + nerdtest.Soci, + nerdtest.SociVersion("0.10.0"), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "convert", "--soci", "--all-platforms", + "--soci-span-size", "2097152", + "--soci-min-layer-size", "0", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, }, } @@ -100,7 +138,11 @@ func TestImageConvertNydusVerify(t *testing.T) { const remoteImageKey = "remoteImageKey" - var registry *testregistry.RegistryServer + var reg *registry.Server + + // It is unclear what is problematic here, but we use the kernel version to discriminate against EL + // See: https://github.com/containerd/nerdctl/issues/4332 + testutil.RequireKernelVersion(t, ">= 6.0.0-0") testCase := &test.Case{ Require: require.All( @@ -110,26 +152,30 @@ func TestImageConvertNydusVerify(t *testing.T) { require.Binary("nydusd"), require.Not(nerdtest.Docker), nerdtest.Rootful, + nerdtest.Registry, ), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - base := testutil.NewBase(t) - registry = testregistry.NewWithNoAuth(base, 0, false) - data.Labels().Set(remoteImageKey, fmt.Sprintf("%s:%d/nydusd-image:test", "localhost", registry.Port)) + reg = nerdtest.RegistryWithNoAuth(data, helpers, 0, false) + reg.Setup(data, helpers) + + data.Labels().Set(remoteImageKey, fmt.Sprintf("%s:%d/nydusd-image:test", "localhost", reg.Port)) helpers.Ensure("image", "convert", "--nydus", "--oci", testutil.CommonImage, data.Identifier("converted-image")) helpers.Ensure("tag", data.Identifier("converted-image"), data.Labels().Get(remoteImageKey)) helpers.Ensure("push", data.Labels().Get(remoteImageKey)) }, Cleanup: func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) - if registry != nil { - registry.Cleanup(nil) + if reg != nil { + reg.Cleanup(data, helpers) helpers.Anyhow("rmi", "-f", data.Labels().Get(remoteImageKey)) } }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { - return helpers.Custom("nydusify", + cmd := helpers.Custom("nydusify", "check", + "--work-dir", + data.Temp().Dir("nydusify-temp"), "--source", testutil.CommonImage, "--target", @@ -137,6 +183,8 @@ func TestImageConvertNydusVerify(t *testing.T) { "--source-insecure", "--target-insecure", ) + cmd.WithTimeout(30 * time.Second) + return cmd }, Expected: test.Expects(0, nil, nil), } diff --git a/cmd/nerdctl/image/image_encrypt_linux_test.go b/cmd/nerdctl/image/image_encrypt_linux_test.go index 40cb742a10c..abdefb0b38b 100644 --- a/cmd/nerdctl/image/image_encrypt_linux_test.go +++ b/cmd/nerdctl/image/image_encrypt_linux_test.go @@ -28,13 +28,13 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" - "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" ) func TestImageEncryptJWE(t *testing.T) { nerdtest.Setup() - var registry *testregistry.RegistryServer + var reg *registry.Server const remoteImageKey = "remoteImageKey" @@ -44,12 +44,14 @@ func TestImageEncryptJWE(t *testing.T) { require.Not(nerdtest.Docker), // This test needs to rmi the common image nerdtest.Private, + nerdtest.Registry, ), Cleanup: func(data test.Data, helpers test.Helpers) { - if registry != nil { - registry.Cleanup(nil) + if reg != nil { + reg.Cleanup(data, helpers) helpers.Anyhow("rmi", "-f", data.Labels().Get(remoteImageKey)) } + helpers.Anyhow("rmi", "-f", data.Identifier("decrypted")) }, Setup: func(data test.Data, helpers test.Helpers) { @@ -57,10 +59,11 @@ func TestImageEncryptJWE(t *testing.T) { data.Labels().Set("private", pri) data.Labels().Set("public", pub) - base := testutil.NewBase(t) - registry = testregistry.NewWithNoAuth(base, 0, false) + reg = nerdtest.RegistryWithNoAuth(data, helpers, 0, false) + reg.Setup(data, helpers) + helpers.Ensure("pull", "--quiet", testutil.CommonImage) - encryptImageRef := fmt.Sprintf("127.0.0.1:%d/%s:encrypted", registry.Port, data.Identifier()) + encryptImageRef := fmt.Sprintf("127.0.0.1:%d/%s:encrypted", reg.Port, data.Identifier()) helpers.Ensure("image", "encrypt", "--recipient=jwe:"+pub, testutil.CommonImage, encryptImageRef) inspector := helpers.Capture("image", "inspect", "--mode=native", "--format={{len .Index.Manifests}}", encryptImageRef) assert.Equal(t, inspector, "1\n") diff --git a/cmd/nerdctl/image/image_history_test.go b/cmd/nerdctl/image/image_history_test.go index 1281c00fa47..1ab939d3f7d 100644 --- a/cmd/nerdctl/image/image_history_test.go +++ b/cmd/nerdctl/image/image_history_test.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/formatter" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -43,6 +44,43 @@ type historyObj struct { Comment string } +const createdAt1 = "2021-03-31T10:21:21-07:00" +const createdAt2 = "2021-03-31T10:21:23-07:00" + +// Expected content of the common image on arm64 +var ( + createdAtTime, _ = time.Parse(time.RFC3339, createdAt2) + expectedHistory = []historyObj{ + { + CreatedBy: "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", + Size: "0B", + CreatedAt: createdAt2, + Snapshot: "", + Comment: "", + CreatedSince: formatter.TimeSinceInHuman(createdAtTime), + }, + { + CreatedBy: "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5…", + Size: "5.947MB", + CreatedAt: createdAt1, + Snapshot: "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c…", + Comment: "", + CreatedSince: formatter.TimeSinceInHuman(createdAtTime), + }, + } + expectedHistoryNoTrunc = []historyObj{ + { + Snapshot: "", + Size: "0", + }, + { + Snapshot: "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a", + CreatedBy: "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5db152fcc582aaccd9e1ec9e3343874e9969a205550fe07d in / ", + Size: "5947392", + }, + } +) + func decode(stdout string) ([]historyObj, error) { dec := json.NewDecoder(strings.NewReader(stdout)) object := []historyObj{} @@ -90,65 +128,65 @@ func TestImageHistory(t *testing.T) { { Description: "trunc, no quiet, human", Command: test.Command("image", "history", "--human=true", "--format=json", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { history, err := decode(stdout) - assert.NilError(t, err, info) - assert.Equal(t, len(history), 2, info) - - localTimeL1, _ := time.Parse(time.RFC3339, "2021-03-31T10:21:23-07:00") - localTimeL2, _ := time.Parse(time.RFC3339, "2021-03-31T10:21:21-07:00") - compTime1, _ := time.Parse(time.RFC3339, history[0].CreatedAt) - compTime2, _ := time.Parse(time.RFC3339, history[1].CreatedAt) - assert.Equal(t, compTime1.UTC().String(), localTimeL1.UTC().String(), info) - assert.Equal(t, history[0].CreatedBy, "/bin/sh -c #(nop) CMD [\"/bin/sh\"]", info) - assert.Equal(t, compTime2.UTC().String(), localTimeL2.UTC().String(), info) - assert.Equal(t, history[1].CreatedBy, "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5…", info) - - assert.Equal(t, history[0].Size, "0B", info) - assert.Equal(t, history[0].CreatedSince, formatter.TimeSinceInHuman(compTime1), info) - assert.Equal(t, history[0].Snapshot, "", info) - assert.Equal(t, history[0].Comment, "", info) - - assert.Equal(t, history[1].Size, "5.947MB", info) - assert.Equal(t, history[1].CreatedSince, formatter.TimeSinceInHuman(compTime2), info) - assert.Equal(t, history[1].Snapshot, "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c…", info) - assert.Equal(t, history[1].Comment, "", info) + assert.NilError(t, err, "decode should not fail") + assert.Equal(t, len(history), 2, "history should be 2 in length") + + h0Time, _ := time.Parse(time.RFC3339, history[0].CreatedAt) + h1Time, _ := time.Parse(time.RFC3339, history[1].CreatedAt) + comp0Time, _ := time.Parse(time.RFC3339, expectedHistory[0].CreatedAt) + comp1Time, _ := time.Parse(time.RFC3339, expectedHistory[1].CreatedAt) + + assert.Equal(t, h0Time.UTC().String(), comp0Time.UTC().String()) + assert.Equal(t, history[0].CreatedBy, expectedHistory[0].CreatedBy) + assert.Equal(t, history[0].Size, expectedHistory[0].Size) + assert.Equal(t, history[0].CreatedSince, expectedHistory[0].CreatedSince) + assert.Equal(t, history[0].Snapshot, expectedHistory[0].Snapshot) + assert.Equal(t, history[0].Comment, expectedHistory[0].Comment) + + assert.Equal(t, h1Time.UTC().String(), comp1Time.UTC().String()) + assert.Equal(t, history[1].CreatedBy, expectedHistory[1].CreatedBy) + assert.Equal(t, history[1].Size, expectedHistory[1].Size) + assert.Equal(t, history[1].CreatedSince, expectedHistory[1].CreatedSince) + assert.Equal(t, history[1].Snapshot, expectedHistory[1].Snapshot) + assert.Equal(t, history[1].Comment, expectedHistory[1].Comment) }), }, { - Description: "no human - dates and sizes and not prettyfied", + Description: "no human - dates and sizes are not prettyfied", Command: test.Command("image", "history", "--human=false", "--format=json", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { history, err := decode(stdout) - assert.NilError(t, err, info) - assert.Equal(t, history[0].Size, "0", info) - assert.Equal(t, history[0].CreatedSince, history[0].CreatedAt, info) - assert.Equal(t, history[1].Size, "5947392", info) - assert.Equal(t, history[1].CreatedSince, history[1].CreatedAt, info) + assert.NilError(t, err, "decode should not fail") + assert.Equal(t, history[0].Size, expectedHistoryNoTrunc[0].Size) + assert.Equal(t, history[0].CreatedSince, history[0].CreatedAt) + assert.Equal(t, history[1].Size, expectedHistoryNoTrunc[1].Size) + assert.Equal(t, history[1].CreatedSince, history[1].CreatedAt) }), }, { Description: "no trunc - do not truncate sha or cmd", Command: test.Command("image", "history", "--human=false", "--no-trunc", "--format=json", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { history, err := decode(stdout) - assert.NilError(t, err, info) - assert.Equal(t, history[1].Snapshot, "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a") - assert.Equal(t, history[1].CreatedBy, "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5db152fcc582aaccd9e1ec9e3343874e9969a205550fe07d in / ") + assert.NilError(t, err, "decode should not fail") + assert.Equal(t, history[1].Snapshot, expectedHistoryNoTrunc[1].Snapshot) + assert.Equal(t, history[1].CreatedBy, expectedHistoryNoTrunc[1].CreatedBy) }), }, { Description: "Quiet has no effect with format, so, go no-json, no-trunc", Command: test.Command("image", "history", "--human=false", "--no-trunc", "--quiet", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { - assert.Equal(t, stdout, "\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n") + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + assert.Equal(t, stdout, expectedHistoryNoTrunc[0].Snapshot+"\n"+expectedHistoryNoTrunc[1].Snapshot+"\n") }), }, { Description: "With quiet, trunc has no effect", Command: test.Command("image", "history", "--human=false", "--no-trunc", "--quiet", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { - assert.Equal(t, stdout, "\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n") + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + assert.Equal(t, stdout, expectedHistoryNoTrunc[0].Snapshot+"\n"+expectedHistoryNoTrunc[1].Snapshot+"\n") }), }, }, diff --git a/cmd/nerdctl/image/image_import.go b/cmd/nerdctl/image/image_import.go new file mode 100644 index 00000000000..555bbcf7e05 --- /dev/null +++ b/cmd/nerdctl/image/image_import.go @@ -0,0 +1,133 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/image" +) + +func ImportCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "import [OPTIONS] file|URL|- [REPOSITORY[:TAG]]", + Short: "Import the contents from a tarball to create a filesystem image", + Args: cobra.MinimumNArgs(1), + RunE: importAction, + ValidArgsFunction: imageImportShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.Flags().StringP("message", "m", "", "Set commit message for imported image") + cmd.Flags().String("platform", "", "Set platform for imported image (e.g., linux/amd64)") + return cmd +} + +func importOptions(cmd *cobra.Command, args []string) (types.ImageImportOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ImageImportOptions{}, err + } + message, err := cmd.Flags().GetString("message") + if err != nil { + return types.ImageImportOptions{}, err + } + platform, err := cmd.Flags().GetString("platform") + if err != nil { + return types.ImageImportOptions{}, err + } + var reference string + if len(args) > 1 { + reference = args[1] + } + + var in io.ReadCloser + src := args[0] + switch { + case src == "-": + in = io.NopCloser(cmd.InOrStdin()) + case hasHTTPPrefix(src): + resp, err := http.Get(src) + if err != nil { + return types.ImageImportOptions{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + return types.ImageImportOptions{}, fmt.Errorf("failed to download %s: %s", src, resp.Status) + } + in = resp.Body + default: + f, err := os.Open(src) + if err != nil { + return types.ImageImportOptions{}, err + } + in = f + } + + return types.ImageImportOptions{ + Stdout: cmd.OutOrStdout(), + Stdin: in, + GOptions: globalOptions, + Source: args[0], + Reference: reference, + Message: message, + Platform: platform, + }, nil +} + +func importAction(cmd *cobra.Command, args []string) error { + opt, err := importOptions(cmd, args) + if err != nil { + return err + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), opt.GOptions.Namespace, opt.GOptions.Address) + if err != nil { + return err + } + defer cancel() + defer func() { + if rc, ok := opt.Stdin.(io.ReadCloser); ok { + _ = rc.Close() + } + }() + + name, err := image.Import(ctx, client, opt) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write([]byte(name + "\n")) + return err +} + +func imageImportShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} + +func hasHTTPPrefix(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} diff --git a/cmd/nerdctl/image/image_import_linux_test.go b/cmd/nerdctl/image/image_import_linux_test.go new file mode 100644 index 00000000000..7052c101a8e --- /dev/null +++ b/cmd/nerdctl/image/image_import_linux_test.go @@ -0,0 +1,205 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "archive/tar" + "bytes" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// minimalRootfsTar returns a valid tar archive with no files. +func minimalRootfsTar(t *testing.T) *bytes.Buffer { + t.Helper() + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + assert.NilError(t, tw.Close()) + return buf +} + +func TestImageImportErrors(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "TestImageImportErrors", + Require: require.Linux, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("import", "", "image:tag") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "no such file or directory", + }), + } + + testCase.Run(t) +} + +func TestImageImport(t *testing.T) { + testCase := nerdtest.Setup() + + var stopServer func() + + testCase.SubTests = []*test.Case{ + { + Description: "image import from stdin", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("import", "-", data.Identifier()) + cmd.Feed(bytes.NewReader(minimalRootfsTar(t).Bytes())) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + imgs := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgs, identifier)) + }, + ), + } + }, + }, + { + Description: "image import from file", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + p := filepath.Join(data.Temp().Path(), "rootfs.tar") + assert.NilError(t, os.WriteFile(p, minimalRootfsTar(t).Bytes(), 0644)) + data.Labels().Set("tar", p) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("import", data.Labels().Get("tar"), data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + imgs := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgs, identifier)) + }, + ), + } + }, + }, + { + Description: "image import with message", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("import", "-m", "A message", "-", data.Identifier()) + cmd.Feed(bytes.NewReader(minimalRootfsTar(t).Bytes())) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + ":latest" + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + img := nerdtest.InspectImage(helpers, identifier) + assert.Equal(t, img.Comment, "A message") + }, + ), + } + }, + }, + { + Description: "image import with platform", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("import", "--platform", "linux/amd64", "-", data.Identifier()) + cmd.Feed(bytes.NewReader(minimalRootfsTar(t).Bytes())) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + ":latest" + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + img := nerdtest.InspectImage(helpers, identifier) + assert.Equal(t, img.Architecture, "amd64") + assert.Equal(t, img.Os, "linux") + }, + ), + } + }, + }, + { + Description: "image import from URL", + Cleanup: func(data test.Data, helpers test.Helpers) { + if stopServer != nil { + stopServer() + stopServer = nil + } + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-tar") + _, _ = w.Write(minimalRootfsTar(t).Bytes()) + }) + url, stop, err := nerdtest.StartHTTPServer(handler) + assert.NilError(t, err) + stopServer = stop + data.Labels().Set("url", url) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("import", data.Labels().Get("url"), data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + return &test.Expected{ + Output: expect.All( + func(stdout string, t tig.T) { + imgs := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgs, identifier)) + }, + ), + } + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/image/image_inspect_test.go b/cmd/nerdctl/image/image_inspect_test.go index f0c53db2346..68124c53d34 100644 --- a/cmd/nerdctl/image/image_inspect_test.go +++ b/cmd/nerdctl/image/image_inspect_test.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -45,14 +46,14 @@ func TestImageInspectSimpleCases(t *testing.T) { { Description: "Contains some stuff", Command: test.Command("image", "inspect", testutil.CommonImage), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) - assert.Assert(t, len(dc[0].RootFS.Layers) > 0, info) - assert.Assert(t, dc[0].Architecture != "", info) - assert.Assert(t, dc[0].Size > 0, info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") + assert.Assert(t, len(dc[0].RootFS.Layers) > 0, "there should be at least one rootfs layer\n") + assert.Assert(t, dc[0].Architecture != "", "architecture should be set\n") + assert.Assert(t, dc[0].Size > 0, "size should be > 0 \n") }), }, { @@ -65,6 +66,18 @@ func TestImageInspectSimpleCases(t *testing.T) { Command: test.Command("image", "inspect", testutil.CommonImage, "--format", "{{.ID}}"), Expected: test.Expects(0, nil, nil), }, + { + Description: "Config.Image field is set", + Command: test.Command("image", "inspect", testutil.CommonImage), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var dc []dockercompat.Image + err := json.Unmarshal([]byte(stdout), &dc) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") + assert.Assert(t, dc[0].Config != nil, "image Config should not be nil") + assert.Assert(t, dc[0].Config.Image != "", "Config.Image should not be empty") + }), + }, { Description: "Error for image not found", Command: test.Command("image", "inspect", "dne:latest", "dne2:latest"), @@ -115,11 +128,11 @@ func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { Command: test.Command("image", "inspect", "busybox"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") reference := dc[0].ID sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") @@ -140,11 +153,11 @@ func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { Command: test.Command("image", "inspect", "busybox"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") reference := dc[0].ID sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") @@ -173,11 +186,11 @@ func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { Command: test.Command("image", "inspect", "busybox"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") for _, id := range []string{"doesnotexist", "doesnotexist:either", "busybox:bogustag"} { @@ -196,11 +209,11 @@ func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { Command: test.Command("image", "inspect", "busybox"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") for _, id := range []string{"∞∞∞∞∞∞∞∞∞∞", "busybox:∞∞∞∞∞∞∞∞∞∞"} { cmd := helpers.Command("image", "inspect", id) @@ -218,11 +231,11 @@ func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { Command: test.Command("image", "inspect", "busybox", "busybox"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Image err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 2, len(dc), "Unexpectedly did not get 2 results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 2, len(dc), "Unexpectedly did not get 2 results\n") reference := nerdtest.InspectImage(helpers, "busybox") assert.Equal(t, dc[0].ID, reference.ID) assert.Equal(t, dc[1].ID, reference.ID) diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go index 6eba01c84c5..228b3a08d1a 100644 --- a/cmd/nerdctl/image/image_list_test.go +++ b/cmd/nerdctl/image/image_list_test.go @@ -19,8 +19,7 @@ package image import ( "errors" "fmt" - "os" - "path/filepath" + "regexp" "runtime" "slices" "strings" @@ -31,7 +30,9 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/tabutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -40,10 +41,12 @@ import ( func TestImages(t *testing.T) { nerdtest.Setup() + commonImage, _ := referenceutil.Parse(testutil.CommonImage) + testCase := &test.Case{ Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { - helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("pull", "--quiet", commonImage.String()) helpers.Ensure("pull", "--quiet", testutil.NginxAlpineImage) }, SubTests: []*test.Case{ @@ -52,53 +55,53 @@ func TestImages(t *testing.T) { Command: test.Command("images"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 2, info) + assert.Assert(t, len(lines) >= 2, "there should be at least two lines\n") header := "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE" if nerdtest.IsDocker() { header = "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE" } tab := tabutil.NewReader(header) err := tab.ParseHeader(lines[0]) - assert.NilError(t, err, info) + assert.NilError(t, err, "ParseHeader should not fail\n") found := false for _, line := range lines[1:] { repo, _ := tab.ReadRow(line, "REPOSITORY") tag, _ := tab.ReadRow(line, "TAG") - if repo+":"+tag == testutil.CommonImage { + if repo+":"+tag == commonImage.FamiliarName()+":"+commonImage.Tag { found = true break } } - assert.Assert(t, found, info) + assert.Assert(t, found, "we should have found an image\n") }, } }, }, { Description: "With names", - Command: test.Command("images", "--names", testutil.CommonImage), + Command: test.Command("images", "--names", commonImage.String()), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( - expect.Contains(testutil.CommonImage), - func(stdout string, info string, t *testing.T) { + expect.Contains(commonImage.String()), + func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 2, info) + assert.Assert(t, len(lines) >= 2, "there should be at least two lines\n") tab := tabutil.NewReader("NAME\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE") err := tab.ParseHeader(lines[0]) - assert.NilError(t, err, info) + assert.NilError(t, err, "ParseHeader should not fail\n") found := false for _, line := range lines[1:] { name, _ := tab.ReadRow(line, "NAME") - if name == testutil.CommonImage { + if name == commonImage.String() { found = true break } } - assert.Assert(t, found, info) + assert.Assert(t, found, "we should have found an image\n") }, ), } @@ -109,12 +112,12 @@ func TestImages(t *testing.T) { Command: test.Command("images", "--format", "'{{json .CreatedAt}}'"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 2, info) + assert.Assert(t, len(lines) >= 2, "there should be at least two lines\n") createdTimes := lines slices.Reverse(createdTimes) - assert.Assert(t, slices.IsSorted(createdTimes), info) + assert.Assert(t, slices.IsSorted(createdTimes), "created times should be sorted\n") }, } }, @@ -135,22 +138,23 @@ func TestImages(t *testing.T) { func TestImagesFilter(t *testing.T) { nerdtest.Setup() + commonImage, _ := referenceutil.Parse(testutil.CommonImage) + testCase := &test.Case{ Require: nerdtest.Build, Setup: func(data test.Data, helpers test.Helpers) { - helpers.Ensure("pull", "--quiet", testutil.CommonImage) - helpers.Ensure("tag", testutil.CommonImage, "taggedimage:one-fragment-one") - helpers.Ensure("tag", testutil.CommonImage, "taggedimage:two-fragment-two") + helpers.Ensure("pull", "--quiet", commonImage.String()) + helpers.Ensure("tag", commonImage.String(), "taggedimage:one-fragment-one") + helpers.Ensure("tag", commonImage.String(), "taggedimage:two-fragment-two") dockerfile := fmt.Sprintf(`FROM %s CMD ["echo", "nerdctl-build-test-string"] \n LABEL foo=bar LABEL version=0.1 RUN echo "actually creating a layer so that docker sets the createdAt time" -`, testutil.CommonImage) +`, commonImage.String()) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") data.Labels().Set("buildCtx", buildCtx) }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -237,47 +241,45 @@ RUN echo "actually creating a layer so that docker sets the createdAt time" Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( - expect.Contains(testutil.ImageRepo(testutil.CommonImage)), + expect.Contains(commonImage.FamiliarName(), commonImage.Tag), expect.DoesNotContain(data.Labels().Get("builtImageID")), ), } }, }, { - Description: "since=" + testutil.CommonImage, - Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage)), + Description: "since=" + commonImage.String(), + Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", commonImage.String())), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("builtImageID")), - expect.DoesNotContain(testutil.ImageRepo(testutil.CommonImage)), + expect.DoesNotMatch(regexp.MustCompile(commonImage.FamiliarName()+"[\\s]+"+commonImage.Tag)), ), } }, }, { - Description: "since=" + testutil.CommonImage + " " + testutil.CommonImage, - Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage), testutil.CommonImage), + Description: "since=" + commonImage.String() + " " + commonImage.String(), + Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", commonImage.String()), commonImage.String()), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: expect.DoesNotContain( - data.Labels().Get("builtImageID"), - testutil.ImageRepo(testutil.CommonImage), + Output: expect.All( + expect.DoesNotContain(data.Labels().Get("builtImageID")), + expect.DoesNotMatch(regexp.MustCompile(commonImage.FamiliarName()+"[\\s]+"+commonImage.Tag)), ), } }, }, { Description: "since=non-exists-image", - Require: nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3511"), Command: test.Command("images", "--filter", "since=non-exists-image"), - Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("No such image: ")}, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("no such image: ")}, nil), }, { Description: "before=non-exists-image", - Require: nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3511"), Command: test.Command("images", "--filter", "before=non-exists-image"), - Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("No such image: ")}, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("no such image: ")}, nil), }, }, } @@ -298,8 +300,7 @@ func TestImagesFilterDangling(t *testing.T) { CMD ["echo", "nerdctl-build-notag-string"] `, testutil.CommonImage) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") data.Labels().Set("buildCtx", buildCtx) }, Cleanup: func(data test.Data, helpers test.Helpers) { @@ -343,7 +344,7 @@ func TestImagesKubeWithKubeHideDupe(t *testing.T) { Command: test.Command("--kube-hide-dupe", "images"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var imageID string var skipLine int lines := strings.Split(strings.TrimSpace(stdout), "\n") @@ -353,7 +354,7 @@ func TestImagesKubeWithKubeHideDupe(t *testing.T) { } tab := tabutil.NewReader(header) err := tab.ParseHeader(lines[0]) - assert.NilError(t, err, info) + assert.NilError(t, err, "ParseHeader should not fail\n") found := true for i, line := range lines[1:] { repo, _ := tab.ReadRow(line, "REPOSITORY") @@ -374,7 +375,7 @@ func TestImagesKubeWithKubeHideDupe(t *testing.T) { break } } - assert.Assert(t, found, info) + assert.Assert(t, found, "We should have found the image\n") }, } }, diff --git a/cmd/nerdctl/image/image_load_test.go b/cmd/nerdctl/image/image_load_test.go index 6598ab93db5..90e8c970d1a 100644 --- a/cmd/nerdctl/image/image_load_test.go +++ b/cmd/nerdctl/image/image_load_test.go @@ -17,7 +17,6 @@ package image import ( - "fmt" "os" "path/filepath" "strings" @@ -28,6 +27,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -60,8 +60,8 @@ func TestLoadStdinFromPipe(t *testing.T) { identifier := data.Identifier() return &test.Expected{ Output: expect.All( - expect.Contains(fmt.Sprintf("Loaded image: %s:latest", identifier)), - func(stdout string, info string, t *testing.T) { + expect.Contains(identifier), + func(stdout string, t tig.T) { assert.Assert(t, strings.Contains(helpers.Capture("images"), identifier)) }, ), @@ -106,7 +106,7 @@ func TestLoadQuiet(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( - expect.Contains(fmt.Sprintf("Loaded image: %s:latest", data.Identifier())), + expect.Contains(data.Identifier()), expect.DoesNotContain("Loading layer"), ), } diff --git a/cmd/nerdctl/image/image_prune_test.go b/cmd/nerdctl/image/image_prune_test.go index 402ea7bb94a..ca3cbf62e95 100644 --- a/cmd/nerdctl/image/image_prune_test.go +++ b/cmd/nerdctl/image/image_prune_test.go @@ -18,8 +18,6 @@ package image import ( "fmt" - "os" - "path/filepath" "strings" "testing" "time" @@ -29,6 +27,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -72,8 +71,7 @@ func TestImagePrune(t *testing.T) { `, testutil.CommonImage) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", buildCtx) // After we rebuild with tag, docker will no longer show the version from above // Swapping order does not change anything. @@ -87,13 +85,13 @@ func TestImagePrune(t *testing.T) { identifier := data.Identifier() return &test.Expected{ Output: expect.All( - func(stdout string, info string, t *testing.T) { - assert.Assert(t, !strings.Contains(stdout, identifier), info) + func(stdout string, t tig.T) { + assert.Assert(t, !strings.Contains(stdout, identifier)) }, - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { imgList := helpers.Capture("images") assert.Assert(t, !strings.Contains(imgList, ""), imgList) - assert.Assert(t, strings.Contains(imgList, identifier), info) + assert.Assert(t, strings.Contains(imgList, identifier)) }, ), } @@ -120,8 +118,7 @@ func TestImagePrune(t *testing.T) { `, testutil.CommonImage) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", buildCtx) helpers.Ensure("build", "-t", identifier, buildCtx) imgList := helpers.Capture("images") @@ -133,18 +130,18 @@ func TestImagePrune(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( - func(stdout string, info string, t *testing.T) { - assert.Assert(t, !strings.Contains(stdout, data.Identifier()), info) + func(stdout string, t tig.T) { + assert.Assert(t, !strings.Contains(stdout, data.Identifier())) }, - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { imgList := helpers.Capture("images") - assert.Assert(t, strings.Contains(imgList, data.Identifier()), info) + assert.Assert(t, strings.Contains(imgList, data.Identifier())) assert.Assert(t, !strings.Contains(imgList, ""), imgList) helpers.Ensure("rm", "-f", data.Identifier()) removed := helpers.Capture("image", "prune", "--force", "--all") - assert.Assert(t, strings.Contains(removed, data.Identifier()), info) + assert.Assert(t, strings.Contains(removed, data.Identifier())) imgList = helpers.Capture("images") - assert.Assert(t, !strings.Contains(imgList, data.Identifier()), info) + assert.Assert(t, !strings.Contains(imgList, data.Identifier())) }, ), } @@ -164,8 +161,7 @@ CMD ["echo", "nerdctl-test-image-prune-filter-label"] LABEL foo=bar LABEL version=0.1`, testutil.CommonImage) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", "-t", data.Identifier(), buildCtx) imgList := helpers.Capture("images") assert.Assert(t, strings.Contains(imgList, data.Identifier()), "Missing "+data.Identifier()) @@ -174,18 +170,18 @@ LABEL version=0.1`, testutil.CommonImage) Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ Output: expect.All( - func(stdout string, info string, t *testing.T) { - assert.Assert(t, !strings.Contains(stdout, data.Identifier()), info) + func(stdout string, t tig.T) { + assert.Assert(t, !strings.Contains(stdout, data.Identifier())) }, - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { imgList := helpers.Capture("images") - assert.Assert(t, strings.Contains(imgList, data.Identifier()), info) + assert.Assert(t, strings.Contains(imgList, data.Identifier())) }, - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { prune := helpers.Capture("image", "prune", "--force", "--all", "--filter", "label=foo=bar") - assert.Assert(t, strings.Contains(prune, data.Identifier()), info) + assert.Assert(t, strings.Contains(prune, data.Identifier())) imgList := helpers.Capture("images") - assert.Assert(t, !strings.Contains(imgList, data.Identifier()), info) + assert.Assert(t, !strings.Contains(imgList, data.Identifier())) }, ), } @@ -204,8 +200,7 @@ LABEL version=0.1`, testutil.CommonImage) RUN echo "Anything, so that we create actual content for docker to set the current time for CreatedAt" CMD ["echo", "nerdctl-test-image-prune-until"]`, testutil.CommonImage) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", "-t", data.Identifier(), buildCtx) imgList := helpers.Capture("images") assert.Assert(t, strings.Contains(imgList, data.Identifier()), "Missing "+data.Identifier()) @@ -216,9 +211,9 @@ CMD ["echo", "nerdctl-test-image-prune-until"]`, testutil.CommonImage) return &test.Expected{ Output: expect.All( expect.DoesNotContain(data.Labels().Get("imageID")), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { imgList := helpers.Capture("images") - assert.Assert(t, strings.Contains(imgList, data.Labels().Get("imageID")), info) + assert.Assert(t, strings.Contains(imgList, data.Labels().Get("imageID"))) }, ), } @@ -235,9 +230,9 @@ CMD ["echo", "nerdctl-test-image-prune-until"]`, testutil.CommonImage) return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("imageID")), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { imgList := helpers.Capture("images") - assert.Assert(t, !strings.Contains(imgList, data.Labels().Get("imageID")), imgList, info) + assert.Assert(t, !strings.Contains(imgList, data.Labels().Get("imageID")), imgList) }, ), } diff --git a/cmd/nerdctl/image/image_pull_linux_test.go b/cmd/nerdctl/image/image_pull_linux_test.go index 6dd12b34aba..5637680e139 100644 --- a/cmd/nerdctl/image/image_pull_linux_test.go +++ b/cmd/nerdctl/image/image_pull_linux_test.go @@ -27,6 +27,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -129,8 +130,8 @@ CMD ["echo", "nerdctl-build-test-string"] data.Temp().Save(dockerfile, "Dockerfile") reg = nerdtest.RegistryWithNoAuth(data, helpers, 80, false) reg.Setup(data, helpers) - testImageRef := fmt.Sprintf("%s/%s:%s", - reg.IP.String(), data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s/%s", + reg.IP.String(), data.Identifier()) buildCtx := data.Temp().Path() helpers.Ensure("build", "-t", testImageRef, buildCtx) @@ -182,7 +183,7 @@ func TestImagePullSoci(t *testing.T) { Setup: func(data test.Data, helpers test.Helpers) { cmd := helpers.Custom("mount") cmd.Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { data.Labels().Set("remoteSnapshotsInitialCount", strconv.Itoa(strings.Count(stdout, "fuse.rawBridge"))) }, }) @@ -196,7 +197,7 @@ func TestImagePullSoci(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, _ string, t *testing.T) { + Output: func(stdout string, t tig.T) { remoteSnapshotsInitialCount, _ := strconv.Atoi(data.Labels().Get("remoteSnapshotsInitialCount")) remoteSnapshotsActualCount := strings.Count(stdout, "fuse.rawBridge") assert.Equal(t, @@ -218,7 +219,7 @@ func TestImagePullSoci(t *testing.T) { Setup: func(data test.Data, helpers test.Helpers) { cmd := helpers.Custom("mount") cmd.Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { data.Labels().Set("remoteSnapshotsInitialCount", strconv.Itoa(strings.Count(stdout, "fuse.rawBridge"))) }, }) @@ -232,7 +233,7 @@ func TestImagePullSoci(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { remoteSnapshotsInitialCount, _ := strconv.Atoi(data.Labels().Get("remoteSnapshotsInitialCount")) remoteSnapshotsActualCount := strings.Count(stdout, "fuse.rawBridge") assert.Equal(t, diff --git a/cmd/nerdctl/image/image_push_linux_test.go b/cmd/nerdctl/image/image_push_linux_test.go index bf10f371a23..c547341d012 100644 --- a/cmd/nerdctl/image/image_push_linux_test.go +++ b/cmd/nerdctl/image/image_push_linux_test.go @@ -20,43 +20,54 @@ import ( "errors" "fmt" "net/http" - "strings" "testing" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" - "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" ) func TestPush(t *testing.T) { nerdtest.Setup() - var registryNoAuthHTTPRandom, registryNoAuthHTTPDefault, registryTokenAuthHTTPSRandom *testregistry.RegistryServer + var registryNoAuthHTTPRandom, registryNoAuthHTTPDefault, registryTokenAuthHTTPSRandom *registry.Server + var tokenServer *registry.TokenAuthServer testCase := &test.Case{ - Require: require.Linux, + Require: require.All( + require.Linux, + nerdtest.Registry, + nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/4470"), + ), Setup: func(data test.Data, helpers test.Helpers) { - base := testutil.NewBase(t) - registryNoAuthHTTPRandom = testregistry.NewWithNoAuth(base, 0, false) - registryNoAuthHTTPDefault = testregistry.NewWithNoAuth(base, 80, false) - registryTokenAuthHTTPSRandom = testregistry.NewWithTokenAuth(base, "admin", "badmin", 0, true) + registryNoAuthHTTPRandom = nerdtest.RegistryWithNoAuth(data, helpers, 0, false) + registryNoAuthHTTPRandom.Setup(data, helpers) + registryNoAuthHTTPDefault = nerdtest.RegistryWithNoAuth(data, helpers, 80, false) + registryNoAuthHTTPDefault.Setup(data, helpers) + registryTokenAuthHTTPSRandom, tokenServer = nerdtest.RegistryWithTokenAuth(data, helpers, "admin", "badmin", 0, true) + tokenServer.Setup(data, helpers) + registryTokenAuthHTTPSRandom.Setup(data, helpers) }, Cleanup: func(data test.Data, helpers test.Helpers) { if registryNoAuthHTTPRandom != nil { - registryNoAuthHTTPRandom.Cleanup(nil) + registryNoAuthHTTPRandom.Cleanup(data, helpers) } if registryNoAuthHTTPDefault != nil { - registryNoAuthHTTPDefault.Cleanup(nil) + registryNoAuthHTTPDefault.Cleanup(data, helpers) } if registryTokenAuthHTTPSRandom != nil { - registryTokenAuthHTTPSRandom.Cleanup(nil) + registryTokenAuthHTTPSRandom.Cleanup(data, helpers) + } + if tokenServer != nil { + tokenServer.Cleanup(data, helpers) } }, @@ -65,8 +76,8 @@ func TestPush(t *testing.T) { Description: "plain http", Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) }, @@ -85,8 +96,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) }, @@ -104,8 +115,8 @@ func TestPush(t *testing.T) { Description: "plain http with localhost", Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - "127.0.0.1", registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + "127.0.0.1", registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) }, @@ -119,8 +130,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s/%s:%s", - registryNoAuthHTTPDefault.IP.String(), data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s/%s", + registryNoAuthHTTPDefault.IP.String(), data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) }, @@ -139,8 +150,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) helpers.Ensure("--insecure-registry", "login", "-u", "admin", "-p", "badmin", @@ -162,8 +173,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.CommonImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.CommonImage, testImageRef) helpers.Ensure("--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, "login", "-u", "admin", "-p", "badmin", @@ -185,8 +196,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.NonDistBlobImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.NonDistBlobImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.NonDistBlobImage, testImageRef) }, @@ -200,12 +211,12 @@ func TestPush(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { blobURL := fmt.Sprintf("http://%s:%d/v2/%s/blobs/%s", registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), testutil.NonDistBlobDigest) resp, err := http.Get(blobURL) assert.Assert(t, err, "error making http request") if resp.Body != nil { - resp.Body.Close() + _ = resp.Body.Close() } assert.Equal(t, resp.StatusCode, http.StatusNotFound, "non-distributable blob should not be available") }, @@ -217,8 +228,8 @@ func TestPush(t *testing.T) { Require: require.Not(nerdtest.Docker), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.NonDistBlobImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.NonDistBlobImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.NonDistBlobImage, testImageRef) }, @@ -232,12 +243,12 @@ func TestPush(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { blobURL := fmt.Sprintf("http://%s:%d/v2/%s/blobs/%s", registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), testutil.NonDistBlobDigest) resp, err := http.Get(blobURL) assert.Assert(t, err, "error making http request") if resp.Body != nil { - resp.Body.Close() + _ = resp.Body.Close() } assert.Equal(t, resp.StatusCode, http.StatusOK, "non-distributable blob should be available") }, @@ -252,8 +263,8 @@ func TestPush(t *testing.T) { ), Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("pull", "--quiet", testutil.UbuntuImage) - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier(), strings.Split(testutil.UbuntuImage, ":")[1]) + testImageRef := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) data.Labels().Set("testImageRef", testImageRef) helpers.Ensure("tag", testutil.UbuntuImage, testImageRef) }, diff --git a/cmd/nerdctl/image/image_remove_test.go b/cmd/nerdctl/image/image_remove_test.go index 11f2f050636..6e3f4ad3e36 100644 --- a/cmd/nerdctl/image/image_remove_test.go +++ b/cmd/nerdctl/image/image_remove_test.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -63,7 +64,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("image is being used")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(repoName), }) @@ -83,7 +84,7 @@ func TestRemove(t *testing.T) { Command: test.Command("rmi", "-f", testutil.CommonImage), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.DoesNotContain(repoName), }) @@ -108,7 +109,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("image is being used")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(repoName), }) @@ -140,7 +141,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(""), }) @@ -162,7 +163,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("image is being used")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(repoName), }) @@ -184,7 +185,7 @@ func TestRemove(t *testing.T) { Command: test.Command("rmi", "-f", testutil.CommonImage), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ // a created container with removed image doesn't impact other `rmi` command Output: expect.DoesNotContain(repoName, nginxRepoName), @@ -212,7 +213,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("image is being used")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(repoName), }) @@ -246,7 +247,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(""), }) @@ -272,7 +273,7 @@ func TestRemove(t *testing.T) { return &test.Expected{ ExitCode: 1, Errors: []error{errors.New("image is being used")}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.Contains(repoName), }) @@ -293,7 +294,7 @@ func TestRemove(t *testing.T) { Command: test.Command("rmi", "-f", testutil.CommonImage), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images").Run(&test.Expected{ Output: expect.DoesNotContain(repoName), }) @@ -336,10 +337,10 @@ func TestIssue3016(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("images", data.Labels().Get(tagIDKey)).Run(&test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Equal(t, len(strings.Split(stdout, "\n")), 2) }, }) @@ -378,17 +379,17 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numTags+1, info) + assert.Assert(t, len(lines) == numTags+1) }, }) helpers.Command("images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numNoTags+1, info) + assert.Assert(t, len(lines) == numNoTags+1) }, }) }, @@ -410,17 +411,17 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numTags+1, info) + assert.Assert(t, len(lines) == numTags+1) }, }) helpers.Command("images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numNoTags+2, info) + assert.Assert(t, len(lines) == numNoTags+2) }, }) }, @@ -440,17 +441,17 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numTags, info) + assert.Assert(t, len(lines) == numTags) }, }) helpers.Command("images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numNoTags, info) + assert.Assert(t, len(lines) == numNoTags) }, }) }, @@ -469,7 +470,7 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Command("--kube-hide-dupe", "rmi", stdout[0:12]).Run(&test.Expected{ ExitCode: 1, Errors: []error{errors.New("multiple IDs found with provided prefix: ")}, @@ -478,9 +479,9 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { ExitCode: 0, }) helpers.Command("images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numNoTags, info) + assert.Assert(t, len(lines) == numNoTags) }, }) }, @@ -499,7 +500,7 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { imgID := strings.Split(stdout, "\n") helpers.Command("--kube-hide-dupe", "rmi", imgID[0]).Run(&test.Expected{ ExitCode: 1, @@ -509,9 +510,9 @@ func TestRemoveKubeWithKubeHideDupe(t *testing.T) { ExitCode: 0, }) helpers.Command("images").Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) == numNoTags, info) + assert.Assert(t, len(lines) == numNoTags) }, }) }, diff --git a/cmd/nerdctl/image/image_save.go b/cmd/nerdctl/image/image_save.go index 4c9f9ef9191..79c0c9cfd0a 100644 --- a/cmd/nerdctl/image/image_save.go +++ b/cmd/nerdctl/image/image_save.go @@ -32,7 +32,7 @@ import ( func SaveCommand() *cobra.Command { var cmd = &cobra.Command{ - Use: "save", + Use: "save [flags] IMAGE [IMAGE...]", Args: cobra.MinimumNArgs(1), Short: "Save one or more images to a tar archive (streamed to STDOUT by default)", Long: "The archive implements both Docker Image Spec v1.2 and OCI Image Spec v1.0.", diff --git a/cmd/nerdctl/image/image_save_test.go b/cmd/nerdctl/image/image_save_test.go index 4f3bf58de6a..7b97f523761 100644 --- a/cmd/nerdctl/image/image_save_test.go +++ b/cmd/nerdctl/image/image_save_test.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" testhelpers "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -48,7 +49,7 @@ func TestSaveContent(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { rootfsPath := filepath.Join(data.Temp().Path(), "rootfs") err := testhelpers.ExtractDockerArchive(filepath.Join(data.Temp().Path(), "out.tar"), rootfsPath) assert.NilError(t, err) @@ -188,7 +189,7 @@ func TestSaveMultipleImagesWithSameIDAndLoad(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: []error{}, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { assert.Equal(t, strings.Count(stdout, data.Labels().Get("id")), 2) }, } diff --git a/cmd/nerdctl/inspect/inspect_test.go b/cmd/nerdctl/inspect/inspect_test.go index 954b0e73eac..8047923efa8 100644 --- a/cmd/nerdctl/inspect/inspect_test.go +++ b/cmd/nerdctl/inspect/inspect_test.go @@ -23,6 +23,7 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -50,23 +51,23 @@ func TestInspectSimpleCase(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var inspectResult []json.RawMessage err := json.Unmarshal([]byte(stdout), &inspectResult) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, len(inspectResult), 2, "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, len(inspectResult), 2, "Unexpectedly got multiple results\n") var dci dockercompat.Image err = json.Unmarshal(inspectResult[0], &dci) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") inspecti := nerdtest.InspectImage(helpers, testutil.CommonImage) - assert.Equal(t, dci.ID, inspecti.ID, info) + assert.Equal(t, dci.ID, inspecti.ID, "id should match\n") var dcc dockercompat.Container err = json.Unmarshal(inspectResult[1], &dcc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") inspectc := nerdtest.InspectContainer(helpers, data.Identifier()) - assert.Assert(t, dcc.ID == inspectc.ID, info) + assert.Equal(t, dcc.ID, inspectc.ID, "id should match\n") }, } }, diff --git a/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go index 9a7b09805b5..d3224ec40d6 100644 --- a/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go +++ b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -254,12 +255,12 @@ COPY index.html /usr/share/nginx/html/index.html testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - resp, err := nettestutil.HTTPGet("http://127.0.0.1:8081", 10, false) + Output: func(stdout string, t tig.T) { + resp, err := nettestutil.HTTPGet("http://127.0.0.1:8081", 5, false) assert.NilError(t, err) respBody, err := io.ReadAll(resp.Body) assert.NilError(t, err) - t.Logf("respBody=%q", respBody) + t.Log(fmt.Sprintf("respBody=%q", respBody)) assert.Assert(t, strings.Contains(string(respBody), data.Identifier("indexhtml"))) }, } @@ -319,8 +320,9 @@ func composeUP(data test.Data, helpers test.Helpers, dockerComposeYAML string, o if !wordpressWorking { ccc := helpers.Capture("ps", "-a") helpers.T().Log(ccc) - helpers.T().Error(helpers.Err("logs", projectName+"-wordpress-1")) - helpers.T().Fatalf("wordpress is not working %v", err) + helpers.T().Log(helpers.Err("logs", projectName+"-wordpress-1")) + helpers.T().Log(fmt.Sprintf("wordpress is not working %v", err)) + helpers.T().FailNow() } helpers.Ensure("compose", "-f", comp.YAMLFullPath(), "down", "-v") diff --git a/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go index 5c044bf36af..993c9388ec6 100644 --- a/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go +++ b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go @@ -19,7 +19,6 @@ package ipfs import ( "fmt" "os" - "path/filepath" "regexp" "strings" "testing" @@ -30,6 +29,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -40,7 +40,7 @@ func pushToIPFS(helpers test.Helpers, name string, opts ...string) string { cmd := helpers.Command("push", "ipfs://"+name) cmd.WithArgs(opts...) cmd.Run(&test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { lines := strings.Split(stdout, "\n") assert.Equal(t, len(lines) >= 2, true) ipfsCID = lines[len(lines)-2] @@ -138,8 +138,7 @@ CMD ["echo", "nerdctl-build-test-string"] `, data.Labels().Get(ipfsImageURLKey)) buildCtx := data.Temp().Path() - err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) - assert.NilError(helpers.T(), err) + data.Temp().Save(dockerfile, "Dockerfile") helpers.Ensure("build", "-t", data.Identifier("built-image"), buildCtx) }, diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 5223a68a959..f8ab56799bd 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -31,6 +31,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/cmd/nerdctl/builder" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/checkpoint" "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/compose" "github.com/containerd/nerdctl/v2/cmd/nerdctl/container" @@ -40,8 +41,10 @@ import ( "github.com/containerd/nerdctl/v2/cmd/nerdctl/internal" "github.com/containerd/nerdctl/v2/cmd/nerdctl/ipfs" "github.com/containerd/nerdctl/v2/cmd/nerdctl/login" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/manifest" "github.com/containerd/nerdctl/v2/cmd/nerdctl/namespace" "github.com/containerd/nerdctl/v2/cmd/nerdctl/network" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/search" "github.com/containerd/nerdctl/v2/cmd/nerdctl/system" "github.com/containerd/nerdctl/v2/cmd/nerdctl/volume" "github.com/containerd/nerdctl/v2/pkg/config" @@ -109,7 +112,7 @@ func usage(c *cobra.Command) error { t += "\n" return t } - s += printCommands("helpers.Management commands", managementCommands) + s += printCommands("Management commands", managementCommands) s += printCommands("Commands", nonManagementCommands) s += Bold("Flags") + ":\n" @@ -188,6 +191,9 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io") rootCmd.PersistentFlags().StringSlice("cdi-spec-dirs", cfg.CDISpecDirs, "The directories to search for CDI spec files. Defaults to /etc/cdi,/var/run/cdi") rootCmd.PersistentFlags().String("userns-remap", cfg.UsernsRemap, "Support idmapping for creating and running containers. This options is only supported on linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively") + helpers.HiddenPersistentStringArrayFlag(rootCmd, "global-dns", cfg.DNS, "Global DNS servers for containers") + helpers.HiddenPersistentStringArrayFlag(rootCmd, "global-dns-opts", cfg.DNSOpts, "Global DNS options for containers") + helpers.HiddenPersistentStringArrayFlag(rootCmd, "global-dns-search", cfg.DNSSearch, "Global DNS search domains for containers") return aliasToBeInherited, nil } @@ -248,12 +254,12 @@ Config file ($NERDCTL_TOML): %s } // Since we store containers' stateful information on the filesystem per namespace, we need namespaces to be - // valid, safe path segments. This is enforced by store.ValidatePathComponent. + // valid, safe path segments. // Note that the container runtime will further enforce additional restrictions on namespace names // (containerd treats namespaces as valid identifiers - eg: alphanumericals + dash, starting with a letter) // See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#path-segment-names for // considerations about path segments identifiers. - if err = store.ValidatePathComponent(globalOptions.Namespace); err != nil { + if err = store.IsFilesystemSafe(globalOptions.Namespace); err != nil { return err } if appNeedsRootlessParentMain(cmd, args) { @@ -284,9 +290,11 @@ Config file ($NERDCTL_TOML): %s container.PauseCommand(), container.UnpauseCommand(), container.CommitCommand(), + container.ExportCommand(), container.WaitCommand(), container.RenameCommand(), container.AttachCommand(), + container.HealthCheckCommand(), // #endregion // Build @@ -298,9 +306,11 @@ Config file ($NERDCTL_TOML): %s image.PushCommand(), image.LoadCommand(), image.SaveCommand(), + image.ImportCommand(), image.TagCommand(), image.RmiCommand(), image.HistoryCommand(), + search.Command(), // #endregion // #region System @@ -340,6 +350,12 @@ Config file ($NERDCTL_TOML): %s // IPFS ipfs.NewIPFSCommand(), + + // Manifest + manifest.Command(), + + // Checkpoint + checkpoint.Command(), ) addApparmorCommand(rootCmd) container.AddCpCommand(rootCmd) diff --git a/cmd/nerdctl/manifest/manifest.go b/cmd/nerdctl/manifest/manifest.go new file mode 100644 index 00000000000..a504c3a4cf0 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest.go @@ -0,0 +1,44 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Annotations: map[string]string{helpers.Category: helpers.Management}, + Use: "manifest", + Short: "Manage image manifests.", + RunE: helpers.UnknownSubcommandAction, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand( + inspectCommand(), + createCommand(), + annotateCommand(), + removeCommand(), + pushCommand(), + ) + + return cmd +} diff --git a/cmd/nerdctl/manifest/manifest_annotate.go b/cmd/nerdctl/manifest/manifest_annotate.go new file mode 100644 index 00000000000..12c20a47e26 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_annotate.go @@ -0,0 +1,98 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" +) + +func annotateCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "annotate INDEX/MANIFESTLIST MANIFEST", + Short: "Add additional information to a local image manifest", + Args: cobra.ExactArgs(2), + RunE: annotateAction, + ValidArgsFunction: annotateShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().String("os", "", "Set operating system") + cmd.Flags().String("arch", "", "Set architecture") + cmd.Flags().String("os-version", "", "Set operating system version") + cmd.Flags().String("variant", "", "Set operating system feature") + cmd.Flags().StringArray("os-features", []string{}, "Set architecture variant") + return cmd +} + +func processAnnotateFlags(cmd *cobra.Command) (types.ManifestAnnotateOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + + os, err := cmd.Flags().GetString("os") + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + arch, err := cmd.Flags().GetString("arch") + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + osVersion, err := cmd.Flags().GetString("os-version") + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + variant, err := cmd.Flags().GetString("variant") + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + osFeatures, err := cmd.Flags().GetStringArray("os-features") + if err != nil { + return types.ManifestAnnotateOptions{}, err + } + + return types.ManifestAnnotateOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Os: os, + Arch: arch, + OsVersion: osVersion, + Variant: variant, + OsFeatures: osFeatures, + }, nil +} + +func annotateAction(cmd *cobra.Command, args []string) error { + annotateOptions, err := processAnnotateFlags(cmd) + if err != nil { + return err + } + + listRef := args[0] + manifestRef := args[1] + + return manifest.Annotate(cmd.Context(), listRef, manifestRef, annotateOptions) +} + +func annotateShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_annotate_linux_test.go b/cmd/nerdctl/manifest/manifest_annotate_linux_test.go new file mode 100644 index 00000000000..fda04cad3c2 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_annotate_linux_test.go @@ -0,0 +1,121 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestManifestAnnotateErrors(t *testing.T) { + testCase := nerdtest.Setup() + manifestListName := "test-list:v1" + manifestName := "example.com/alpine:latest" + invalidName := "invalid/name/with/special@chars" + testCase.SubTests = []*test.Case{ + { + Description: "too-few-arguments", + Command: test.Command("manifest", "annotate", manifestListName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "invalid-list-name", + Command: test.Command("manifest", "annotate", invalidName, manifestName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + { + Description: "invalid-manifest-reference", + Command: test.Command("manifest", "annotate", manifestListName, invalidName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + } + + testCase.Run(t) +} + +func TestManifestAnnotate(t *testing.T) { + testCase := nerdtest.Setup() + manifestListName := "example.com/test-list-annotate:v1" + manifestRef := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/amd64") + + testCase.SubTests = []*test.Case{ + { + Description: "annotate-non-existent-manifest", + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("manifest", "create", manifestListName, manifestRef) + cmd.Run(&test.Expected{ExitCode: 0}) + }, + Command: test.Command("manifest", "annotate", manifestListName, "example.com/fake:0.0"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "manifest for image example.com/fake:0.0 does not exist", + }), + }, + { + Description: "annotate-success", + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("manifest", "create", manifestListName+"-success", manifestRef) + cmd.Run(&test.Expected{ExitCode: 0}) + }, + Command: test.Command("manifest", "annotate", + manifestListName+"-success", + manifestRef, + "--os", "freebsd", + "--arch", "arm", + "--os-version", "1", + "--os-features", "feature1", + "--variant", "v7"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_create.go b/cmd/nerdctl/manifest/manifest_create.go new file mode 100644 index 00000000000..0a8a9f586dc --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_create.go @@ -0,0 +1,87 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" +) + +func createCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "create INDEX/MANIFESTLIST MANIFEST [MANIFEST...]", + Short: "Create a local index/manifest list for annotating and pushing to a registry", + Args: cobra.MinimumNArgs(2), + RunE: createAction, + ValidArgsFunction: createShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("amend", false, "Amend the existing index/manifest list") + cmd.Flags().Bool("insecure", false, "Allow communication with an insecure registry") + return cmd +} + +func processCreateFlags(cmd *cobra.Command) (types.ManifestCreateOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestCreateOptions{}, err + } + amend, err := cmd.Flags().GetBool("amend") + if err != nil { + return types.ManifestCreateOptions{}, err + } + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return types.ManifestCreateOptions{}, err + } + return types.ManifestCreateOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Amend: amend, + Insecure: insecure, + }, nil +} + +func createAction(cmd *cobra.Command, args []string) error { + createOptions, err := processCreateFlags(cmd) + if err != nil { + return err + } + + listRef := args[0] + manifestRefs := args[1:] + + listRef, err = manifest.Create(cmd.Context(), listRef, manifestRefs, createOptions) + if err != nil { + return err + } + + fmt.Fprintln(createOptions.Stdout, "Created manifest list", listRef) + + return nil +} + +func createShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_create_linux_test.go b/cmd/nerdctl/manifest/manifest_create_linux_test.go new file mode 100644 index 00000000000..d3588facd2f --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_create_linux_test.go @@ -0,0 +1,135 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestManifestCreateErrors(t *testing.T) { + testCase := nerdtest.Setup() + manifestListName := "test-list:v1" + manifestName := "example.com/alpine:latest" + invalidName := "invalid/name/with/special@chars" + testCase.SubTests = []*test.Case{ + { + Description: "too-few-arguments", + Command: test.Command("manifest", "create", manifestListName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "requires at least 2 arg", + }), + }, + { + Description: "invalid-list-name", + Command: test.Command("manifest", "create", invalidName, manifestName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + { + Description: "invalid-manifest-reference", + Command: test.Command("manifest", "create", manifestListName, invalidName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + } + + testCase.Run(t) +} + +func TestManifestCreate(t *testing.T) { + testCase := nerdtest.Setup() + manifestListName := "test-list-create:v1" + manifestRef := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/amd64") + testCase.SubTests = []*test.Case{ + { + Description: "create-manifest-list", + Command: test.Command("manifest", "create", manifestListName, manifestRef), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Contains(data.Labels().Get("output")), + } + }, + Data: test.WithLabels(map[string]string{ + "output": "Created manifest list docker.io/library/" + manifestListName, + }), + }, + { + Description: "create-existed-manifest-list-without-amend-flag", + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("manifest", "create", manifestListName+"-without-amend-flag", manifestRef) + cmd.Run(&test.Expected{ExitCode: 0}) + }, + Command: test.Command("manifest", "create", manifestListName+"-without-amend-flag", manifestRef), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "refusing to amend an existing manifest list with no --amend flag", + }), + }, + { + Description: "create-manifest-list-with-amend-flag", + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("manifest", "create", manifestListName+"-with-amend-flag", manifestRef) + cmd.Run(&test.Expected{ExitCode: 0}) + }, + Command: test.Command("manifest", "create", "--amend", manifestListName+"-with-amend-flag", manifestRef), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Contains(data.Labels().Get("output")), + } + }, + Data: test.WithLabels(map[string]string{ + "output": "Created manifest list docker.io/library/" + manifestListName + "-with-amend-flag", + }), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_inspect.go b/cmd/nerdctl/manifest/manifest_inspect.go new file mode 100644 index 00000000000..fa0393e4245 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_inspect.go @@ -0,0 +1,95 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" + "github.com/containerd/nerdctl/v2/pkg/formatter" +) + +func inspectCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "inspect MANIFEST", + Short: "Display the contents of a manifest or image index/manifest list", + Args: cobra.MinimumNArgs(1), + RunE: inspectAction, + ValidArgsFunction: inspectShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("verbose", false, "Verbose output additional info including layers and platform") + cmd.Flags().Bool("insecure", false, "Allow communication with an insecure registry") + return cmd +} + +func processInspectFlags(cmd *cobra.Command) (types.ManifestInspectOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestInspectOptions{}, err + } + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return types.ManifestInspectOptions{}, err + } + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return types.ManifestInspectOptions{}, err + } + return types.ManifestInspectOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Verbose: verbose, + Insecure: insecure, + }, nil +} + +func inspectAction(cmd *cobra.Command, args []string) error { + inspectOptions, err := processInspectFlags(cmd) + if err != nil { + return err + } + rawRef := args[0] + res, err := manifest.Inspect(cmd.Context(), rawRef, inspectOptions) + if err != nil { + return err + } + + // Output format: single object for single result, array for multiple results + if len(res) == 1 { + jsonStr, err := formatter.ToJSON(res[0], "", " ") + if err != nil { + return err + } + fmt.Fprint(inspectOptions.Stdout, jsonStr) + } else { + if formatErr := formatter.FormatSlice("", inspectOptions.Stdout, res); formatErr != nil { + return formatErr + } + } + return nil +} + +func inspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_inspect_linux_test.go b/cmd/nerdctl/manifest/manifest_inspect_linux_test.go new file mode 100644 index 00000000000..a9ca1914013 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_inspect_linux_test.go @@ -0,0 +1,149 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "encoding/json" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +const ( + testImageName = "alpine" + testPlatform = "linux/amd64" +) + +type testData struct { + imageName string + platform string + imageRef string + manifestDigest string + configDigest string + rawData string +} + +func newTestData(imageName, platform string) *testData { + return &testData{ + imageName: imageName, + platform: platform, + imageRef: testutil.GetTestImage(imageName), + manifestDigest: testutil.GetTestImageManifestDigest(imageName, platform), + configDigest: testutil.GetTestImageConfigDigest(imageName, platform), + rawData: testutil.GetTestImageRaw(imageName, platform), + } +} + +func (td *testData) imageWithDigest() string { + return testutil.GetTestImageWithoutTag(td.imageName) + "@" + td.manifestDigest +} + +func (td *testData) isAmd64Platform(platform *ocispec.Platform) bool { + return platform != nil && + platform.Architecture == "amd64" && + platform.OS == "linux" +} + +func TestManifestInspect(t *testing.T) { + testCase := nerdtest.Setup() + td := newTestData(testImageName, testPlatform) + + testCase.SubTests = []*test.Case{ + { + Description: "tag-non-verbose", + Command: test.Command("manifest", "inspect", td.imageRef), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var manifest manifesttypes.DockerManifestListStruct + assert.NilError(t, json.Unmarshal([]byte(stdout), &manifest)) + + assert.Equal(t, manifest.SchemaVersion, testutil.GetTestImageSchemaVersion(td.imageName)) + assert.Equal(t, manifest.MediaType, testutil.GetTestImageMediaType(td.imageName)) + assert.Assert(t, len(manifest.Manifests) > 0) + + var foundManifest *ocispec.Descriptor + for _, m := range manifest.Manifests { + if td.isAmd64Platform(m.Platform) { + foundManifest = &m + break + } + } + assert.Assert(t, foundManifest != nil, "should find amd64 platform manifest") + assert.Equal(t, foundManifest.Digest.String(), td.manifestDigest) + assert.Equal(t, foundManifest.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + }), + }, + { + Description: "tag-verbose", + Command: test.Command("manifest", "inspect", td.imageRef, "--verbose"), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var entries []manifesttypes.DockerManifestEntry + assert.NilError(t, json.Unmarshal([]byte(stdout), &entries)) + assert.Assert(t, len(entries) > 0) + + var foundEntry *manifesttypes.DockerManifestEntry + for _, e := range entries { + if td.isAmd64Platform(e.Descriptor.Platform) { + foundEntry = &e + break + } + } + assert.Assert(t, foundEntry != nil, "should find amd64 platform entry") + + expectedRef := td.imageRef + "@" + td.manifestDigest + assert.Equal(t, foundEntry.Ref, expectedRef) + assert.Equal(t, foundEntry.Descriptor.Digest.String(), td.manifestDigest) + assert.Equal(t, foundEntry.Descriptor.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, foundEntry.Raw, td.rawData) + }), + }, + { + Description: "digest-non-verbose", + Command: test.Command("manifest", "inspect", td.imageWithDigest()), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var manifest manifesttypes.DockerManifestStruct + assert.NilError(t, json.Unmarshal([]byte(stdout), &manifest)) + + assert.Equal(t, manifest.SchemaVersion, testutil.GetTestImageSchemaVersion(td.imageName)) + assert.Equal(t, manifest.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, manifest.Config.Digest.String(), td.configDigest) + }), + }, + { + Description: "digest-verbose", + Command: test.Command("manifest", "inspect", td.imageWithDigest(), "--verbose"), + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { + var entry manifesttypes.DockerManifestEntry + assert.NilError(t, json.Unmarshal([]byte(stdout), &entry)) + + assert.Equal(t, entry.Ref, td.imageWithDigest()) + assert.Equal(t, entry.Descriptor.Digest.String(), td.manifestDigest) + assert.Equal(t, entry.Descriptor.MediaType, testutil.GetTestImagePlatformMediaType(td.imageName, td.platform)) + assert.Equal(t, entry.Raw, td.rawData) + }), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_push.go b/cmd/nerdctl/manifest/manifest_push.go new file mode 100644 index 00000000000..be135dbffbb --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_push.go @@ -0,0 +1,80 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" +) + +func pushCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "push [OPTIONS] INDEX/MANIFESTLIST", + Short: "Push a manifest list to a registry", + Args: cobra.ExactArgs(1), + RunE: pushAction, + ValidArgsFunction: pushShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().Bool("insecure", false, "Allow communication with an insecure registry") + cmd.Flags().Bool("purge", false, "Remove the manifest list after pushing") + return cmd +} + +func processPushFlags(cmd *cobra.Command) (types.ManifestPushOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ManifestPushOptions{}, err + } + + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return types.ManifestPushOptions{}, err + } + purge, err := cmd.Flags().GetBool("purge") + if err != nil { + return types.ManifestPushOptions{}, err + } + + return types.ManifestPushOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + Insecure: insecure, + Purge: purge, + }, nil +} + +func pushAction(cmd *cobra.Command, args []string) error { + pushOptions, err := processPushFlags(cmd) + if err != nil { + return err + } + err = manifest.Push(cmd.Context(), args[0], pushOptions) + if err != nil { + return err + } + return nil +} + +func pushShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_push_linux_test.go b/cmd/nerdctl/manifest/manifest_push_linux_test.go new file mode 100644 index 00000000000..c254b33c09b --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_push_linux_test.go @@ -0,0 +1,147 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "errors" + "fmt" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" +) + +func TestManifestPushErrors(t *testing.T) { + testCase := nerdtest.Setup() + invalidName := "invalid/name/with/special@chars" + testCase.SubTests = []*test.Case{ + { + Description: "require-one-argument", + Command: test.Command("manifest", "push", "arg1", "arg2"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + } + }, + }, + { + Description: "invalid-list-name", + Command: test.Command("manifest", "push", invalidName), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "invalid reference format", + }), + }, + } + + testCase.Run(t) +} + +func TestManifestPush(t *testing.T) { + nerdtest.Setup() + + var registryTokenAuthHTTPSRandom *registry.Server + var tokenServer *registry.TokenAuthServer + + manifestRef := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/amd64") + expectedDigest := "sha256:5317ce2da263afa23570c692d62c1b01381285b2198b3ea9739ce64bec22aff2" + + testCase := &test.Case{ + Require: require.All( + require.Linux, + nerdtest.Registry, + ), + Setup: func(data test.Data, helpers test.Helpers) { + registryTokenAuthHTTPSRandom, tokenServer = nerdtest.RegistryWithTokenAuth(data, helpers, "admin", "badmin", 0, true) + tokenServer.Setup(data, helpers) + registryTokenAuthHTTPSRandom.Setup(data, helpers) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if registryTokenAuthHTTPSRandom != nil { + registryTokenAuthHTTPSRandom.Cleanup(data, helpers) + } + if tokenServer != nil { + tokenServer.Cleanup(data, helpers) + } + }, + SubTests: []*test.Case{ + { + Description: "push-to-registry", + Require: require.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + helpers.Ensure("pull", manifestRef) + helpers.Ensure("tag", manifestRef, targetRef) + helpers.Ensure("--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, "login", "-u", "admin", "-p", "badmin", + fmt.Sprintf("%s:%d", registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port)) + helpers.Ensure("push", "--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, targetRef) + helpers.Ensure("rmi", targetRef) + helpers.Ensure("manifest", "create", "--insecure", targetRef+"-success", targetRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + return helpers.Command("manifest", "push", "--insecure", targetRef+"-success") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Contains(data.Labels().Get("output")), + } + }, + Data: test.WithLabels(map[string]string{ + "output": expectedDigest, + }), + }, + { + Description: "reject-cross-registry-sources", + Require: require.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + helpers.Ensure("manifest", "create", "--insecure", targetRef+"-cross", manifestRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + targetRef := fmt.Sprintf("%s:%d/%s", + registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port, "test-list-push:v1") + return helpers.Command("manifest", "push", "--insecure", targetRef+"-cross") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "cannot use source images from a different registry than the target image:", + }), + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_remove.go b/cmd/nerdctl/manifest/manifest_remove.go new file mode 100644 index 00000000000..822aeec0ed4 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_remove.go @@ -0,0 +1,59 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/cmd/manifest" +) + +func removeCommand() *cobra.Command { + var cmd = &cobra.Command{ + Use: "rm INDEX/MANIFESTLIST [INDEX/MANIFESTLIST...]", + Short: "Remove one or more index/manifest lists", + Args: cobra.MinimumNArgs(1), + RunE: removeAction, + ValidArgsFunction: removeShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + return cmd +} + +func removeAction(cmd *cobra.Command, refs []string) error { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + var errs []error + for _, ref := range refs { + err := manifest.Remove(cmd.Context(), ref, globalOptions) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func removeShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.ImageNames(cmd) +} diff --git a/cmd/nerdctl/manifest/manifest_remove_linux_test.go b/cmd/nerdctl/manifest/manifest_remove_linux_test.go new file mode 100644 index 00000000000..ca8fcd72969 --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_remove_linux_test.go @@ -0,0 +1,68 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "errors" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +func TestManifestsRemove(t *testing.T) { + testCase := nerdtest.Setup() + manifestListName1 := "example.com/test-list-remove:v1" + manifestListName2 := "example.com/test-list-remove:v2" + manifestRef1 := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/amd64") + manifestRef2 := testutil.GetTestImageWithoutTag("alpine") + "@" + testutil.GetTestImageManifestDigest("alpine", "linux/arm64") + + testCase.SubTests = []*test.Case{ + { + Description: "remove-several-manifestlists", + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("manifest", "create", manifestListName1, manifestRef1) + cmd.Run(&test.Expected{ExitCode: 0}) + cmd = helpers.Command("manifest", "create", manifestListName2, manifestRef2) + cmd.Run(&test.Expected{ExitCode: 0}) + }, + Command: test.Command("manifest", "rm", manifestListName1, manifestListName2), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + } + }, + }, + { + Description: "remove-non-existent-manifestlist", + Command: test.Command("manifest", "rm", "example.com/non-existent:latest"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New(data.Labels().Get("error"))}, + } + }, + Data: test.WithLabels(map[string]string{ + "error": "No such manifest: example.com/non-existent:latest", + }), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/manifest/manifest_test.go b/cmd/nerdctl/manifest/manifest_test.go new file mode 100644 index 00000000000..d4ec523683b --- /dev/null +++ b/cmd/nerdctl/manifest/manifest_test.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/namespace/namespace.go b/cmd/nerdctl/namespace/namespace.go index 133e63cd6f1..0e88c8a4e17 100644 --- a/cmd/nerdctl/namespace/namespace.go +++ b/cmd/nerdctl/namespace/namespace.go @@ -17,19 +17,9 @@ package namespace import ( - "fmt" - "sort" - "strings" - "text/tabwriter" - "github.com/spf13/cobra" - "github.com/containerd/containerd/v2/pkg/namespaces" - "github.com/containerd/log" - "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" - "github.com/containerd/nerdctl/v2/pkg/clientutil" - "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" ) func Command() *cobra.Command { @@ -50,90 +40,3 @@ func Command() *cobra.Command { cmd.AddCommand(inspectCommand()) return cmd } - -func listCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "ls", - Aliases: []string{"list"}, - Short: "List containerd namespaces", - RunE: listAction, - SilenceUsage: true, - SilenceErrors: true, - } - cmd.Flags().BoolP("quiet", "q", false, "Only display names") - return cmd -} - -func listAction(cmd *cobra.Command, args []string) error { - globalOptions, err := helpers.ProcessRootCmdFlags(cmd) - if err != nil { - return err - } - client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) - if err != nil { - return err - } - defer cancel() - - nsService := client.NamespaceService() - nsList, err := nsService.List(ctx) - if err != nil { - return err - } - quiet, err := cmd.Flags().GetBool("quiet") - if err != nil { - return err - } - if quiet { - for _, ns := range nsList { - fmt.Fprintln(cmd.OutOrStdout(), ns) - } - return nil - } - dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) - if err != nil { - return err - } - - w := tabwriter.NewWriter(cmd.OutOrStdout(), 4, 8, 4, ' ', 0) - // no "NETWORKS", because networks are global objects - fmt.Fprintln(w, "NAME\tCONTAINERS\tIMAGES\tVOLUMES\tLABELS") - for _, ns := range nsList { - ctx = namespaces.WithNamespace(ctx, ns) - var numContainers, numImages, numVolumes int - var labelStrings []string - - containers, err := client.Containers(ctx) - if err != nil { - log.L.Warn(err) - } - numContainers = len(containers) - - images, err := client.ImageService().List(ctx) - if err != nil { - log.L.Warn(err) - } - numImages = len(images) - - volStore, err := volumestore.New(dataStore, ns) - if err != nil { - log.L.Warn(err) - } else { - numVolumes, err = volStore.Count() - if err != nil { - log.L.Warn(err) - } - } - - labels, err := client.NamespaceService().Labels(ctx, ns) - if err != nil { - return err - } - for k, v := range labels { - labelStrings = append(labelStrings, strings.Join([]string{k, v}, "=")) - } - sort.Strings(labelStrings) - fmt.Fprintf(w, "%s\t%d\t%d\t%d\t%v\t\n", ns, numContainers, numImages, numVolumes, strings.Join(labelStrings, ",")) - } - return w.Flush() -} diff --git a/cmd/nerdctl/namespace/namespace_inspect.go b/cmd/nerdctl/namespace/namespace_inspect.go index 8afe47c21cd..57c3ba5a197 100644 --- a/cmd/nerdctl/namespace/namespace_inspect.go +++ b/cmd/nerdctl/namespace/namespace_inspect.go @@ -19,6 +19,7 @@ package namespace import ( "github.com/spf13/cobra" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" @@ -27,12 +28,13 @@ import ( func inspectCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "inspect NAMESPACE", - Short: "Display detailed information on one or more namespaces.", - RunE: inspectAction, - Args: cobra.MinimumNArgs(1), - SilenceUsage: true, - SilenceErrors: true, + Use: "inspect NAMESPACE", + Short: "Display detailed information on one or more namespaces.", + RunE: inspectAction, + ValidArgsFunction: namespaceInspectShellComplete, + Args: cobra.MinimumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, } cmd.Flags().StringP("format", "f", "", "Format the output using the given Go template, e.g, '{{json .}}'") cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -71,3 +73,7 @@ func inspectAction(cmd *cobra.Command, args []string) error { return namespace.Inspect(ctx, client, args, options) } + +func namespaceInspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.NamespaceNames(cmd, args, toComplete) +} diff --git a/cmd/nerdctl/namespace/namespace_list.go b/cmd/nerdctl/namespace/namespace_list.go new file mode 100644 index 00000000000..d1c81dd1713 --- /dev/null +++ b/cmd/nerdctl/namespace/namespace_list.go @@ -0,0 +1,76 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package namespace + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/namespace" +) + +func listCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List containerd namespaces", + RunE: listAction, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().BoolP("quiet", "q", false, "Only display names") + cmd.Flags().StringP("format", "f", "", "Format the output using the given Go template, e.g, '{{json .}}'") + return cmd +} + +func listOptions(cmd *cobra.Command) (types.NamespaceListOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.NamespaceListOptions{}, err + } + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.NamespaceListOptions{}, err + } + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return types.NamespaceListOptions{}, err + } + return types.NamespaceListOptions{ + GOptions: globalOptions, + Format: format, + Quiet: quiet, + Stdout: cmd.OutOrStdout(), + }, nil +} + +func listAction(cmd *cobra.Command, args []string) error { + options, err := listOptions(cmd) + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + return namespace.List(ctx, client, options) +} diff --git a/cmd/nerdctl/namespace/namespace_remove.go b/cmd/nerdctl/namespace/namespace_remove.go index 5624e2d9d80..5206b5e7ded 100644 --- a/cmd/nerdctl/namespace/namespace_remove.go +++ b/cmd/nerdctl/namespace/namespace_remove.go @@ -19,6 +19,7 @@ package namespace import ( "github.com/spf13/cobra" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" @@ -27,13 +28,14 @@ import ( func removeCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "remove [flags] NAMESPACE [NAMESPACE...]", - Aliases: []string{"rm"}, - Args: cobra.MinimumNArgs(1), - Short: "Remove one or more namespaces", - RunE: removeAction, - SilenceUsage: true, - SilenceErrors: true, + Use: "remove [flags] NAMESPACE [NAMESPACE...]", + Aliases: []string{"rm"}, + Args: cobra.MinimumNArgs(1), + Short: "Remove one or more namespaces", + RunE: removeAction, + ValidArgsFunction: namespaceRemoveShellComplete, + SilenceUsage: true, + SilenceErrors: true, } cmd.Flags().BoolP("cgroup", "c", false, "delete the namespace's cgroup") return cmd @@ -69,3 +71,7 @@ func removeAction(cmd *cobra.Command, args []string) error { return namespace.Remove(ctx, client, args, options) } + +func namespaceRemoveShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.NamespaceNames(cmd, args, toComplete) +} diff --git a/cmd/nerdctl/namespace/namespace_update.go b/cmd/nerdctl/namespace/namespace_update.go index 1909d90e701..0e02f78a9c8 100644 --- a/cmd/nerdctl/namespace/namespace_update.go +++ b/cmd/nerdctl/namespace/namespace_update.go @@ -19,6 +19,7 @@ package namespace import ( "github.com/spf13/cobra" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" @@ -27,14 +28,16 @@ import ( func updateCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "update [flags] NAMESPACE", - Short: "Update labels for a namespace", - RunE: updateAction, - Args: cobra.MinimumNArgs(1), - SilenceUsage: true, - SilenceErrors: true, + Use: "update [flags] NAMESPACE", + Short: "Update labels for a namespace", + RunE: updateAction, + ValidArgsFunction: namespaceUpdateShellComplete, + Args: cobra.MinimumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, } - cmd.Flags().StringArrayP("label", "l", nil, "Set labels for a namespace") + cmd.Flags().StringArrayP("label", "l", nil, "Set labels for a namespace (required)") + cmd.MarkFlagRequired("label") return cmd } @@ -67,3 +70,7 @@ func updateAction(cmd *cobra.Command, args []string) error { return namespace.Update(ctx, client, args[0], options) } + +func namespaceUpdateShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.NamespaceNames(cmd, args, toComplete) +} diff --git a/cmd/nerdctl/network/network_create.go b/cmd/nerdctl/network/network_create.go index f1ba2de2473..720a6ff1cec 100644 --- a/cmd/nerdctl/network/network_create.go +++ b/cmd/nerdctl/network/network_create.go @@ -42,6 +42,7 @@ func createCommand() *cobra.Command { cmd.Flags().StringP("driver", "d", DefaultNetworkDriver, "Driver to manage the Network") cmd.RegisterFlagCompletionFunc("driver", completion.NetworkDrivers) cmd.Flags().StringArrayP("opt", "o", nil, "Set driver specific options") + cmd.RegisterFlagCompletionFunc("opt", completion.NetworkOptions) cmd.Flags().String("ipam-driver", "default", "IP Address helpers.Management Driver") cmd.RegisterFlagCompletionFunc("ipam-driver", completion.IPAMDrivers) cmd.Flags().StringArray("ipam-opt", nil, "Set IPAM driver specific options") @@ -50,6 +51,7 @@ func createCommand() *cobra.Command { cmd.Flags().String("ip-range", "", `Allocate container ip from a sub-range`) cmd.Flags().StringArray("label", nil, "Set metadata for a network") cmd.Flags().Bool("ipv6", false, "Enable IPv6 networking") + cmd.Flags().Bool("internal", false, "Restrict external access to the network") return cmd } @@ -99,6 +101,10 @@ func createAction(cmd *cobra.Command, args []string) error { if err != nil { return err } + internal, err := cmd.Flags().GetBool("internal") + if err != nil { + return err + } return network.Create(types.NetworkCreateOptions{ GOptions: globalOptions, @@ -112,5 +118,6 @@ func createAction(cmd *cobra.Command, args []string) error { IPRange: ipRangeStr, Labels: labels, IPv6: ipv6, + Internal: internal, }, cmd.OutOrStdout()) } diff --git a/cmd/nerdctl/network/network_create_linux_test.go b/cmd/nerdctl/network/network_create_linux_test.go index 01ed943467f..f9c35c845aa 100644 --- a/cmd/nerdctl/network/network_create_linux_test.go +++ b/cmd/nerdctl/network/network_create_linux_test.go @@ -17,6 +17,7 @@ package network import ( + "encoding/json" "fmt" "net" "strings" @@ -25,7 +26,9 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -58,9 +61,9 @@ func TestNetworkCreate(t *testing.T) { return &test.Expected{ ExitCode: 0, Errors: nil, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, strings.Contains(stdout, data.Labels().Get("subnet")), info) - assert.Assert(t, !strings.Contains(data.Labels().Get("container2"), data.Labels().Get("subnet")), info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, data.Labels().Get("subnet"))) + assert.Assert(t, !strings.Contains(data.Labels().Get("container2"), data.Labels().Get("subnet"))) }, } }, @@ -98,7 +101,7 @@ func TestNetworkCreate(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { _, subnet, _ := net.ParseCIDR(data.Labels().Get("subnetStr")) ip := nerdtest.FindIPv6(stdout) assert.Assert(t, subnet.Contains(ip), fmt.Sprintf("subnet %s contains ip %s", subnet, ip)) @@ -106,6 +109,150 @@ func TestNetworkCreate(t *testing.T) { } }, }, + { + Description: "internal enabled", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", "--internal", data.Identifier()) + netw := nerdtest.InspectNetwork(helpers, data.Identifier()) + assert.Equal(t, len(netw.IPAM.Config), 1) + data.Labels().Set("subnet", netw.IPAM.Config[0].Subnet) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", "--net", data.Identifier(), testutil.CommonImage, "ip", "route") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, data.Labels().Get("subnet"))) + assert.Assert(t, !strings.Contains(stdout, "default ")) + if nerdtest.IsDocker() { + return + } + nativeNet := nerdtest.InspectNetworkNative(helpers, data.Identifier()) + var cni struct { + Plugins []struct { + Type string `json:"type"` + IsGW bool `json:"isGateway"` + IPMasq bool `json:"ipMasq"` + } `json:"plugins"` + } + _ = json.Unmarshal(nativeNet.CNI, &cni) + // bridge plugin assertions and no portmap + foundBridge := false + for _, p := range cni.Plugins { + assert.Assert(t, p.Type != "portmap") + if p.Type == "bridge" { + foundBridge = true + assert.Assert(t, !p.IsGW) + assert.Assert(t, !p.IPMasq) + } + } + assert.Assert(t, foundBridge) + }, + } + }, + }, + } + + testCase.Run(t) +} + +func TestNetworkCreateICC(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = require.All( + require.Linux, + ) + + testCase.SubTests = []*test.Case{ + { + Description: "with enable_icc=false", + Require: nerdtest.CNIFirewallVersion("1.7.1"), + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + // Create a network with ICC disabled + helpers.Ensure("network", "create", data.Identifier(), "--driver", "bridge", + "--opt", "com.docker.network.bridge.enable_icc=false") + + // Run a container in that network + data.Labels().Set("container1", helpers.Capture("run", "-d", "--net", data.Identifier(), + "--name", data.Identifier("c1"), testutil.CommonImage, "sleep", "infinity")) + + // Wait for container to be running + nerdtest.EnsureContainerStarted(helpers, data.Identifier("c1")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Identifier("c1")) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // DEBUG: Check br_netfilter module status + helpers.Custom("sh", "-ec", "lsmod | grep br_netfilter || echo 'br_netfilter not loaded'").Run(&test.Expected{}) + helpers.Custom("sh", "-ec", "cat /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null || echo 'bridge-nf-call-iptables not available'").Run(&test.Expected{}) + helpers.Custom("sh", "-ec", "ls /proc/sys/net/bridge/ 2>/dev/null || echo 'bridge sysctl not available'").Run(&test.Expected{}) + // Try to ping the other container in the same network + // This should fail when ICC is disabled + return helpers.Command("run", "--rm", "--net", data.Identifier(), + testutil.CommonImage, "ping", "-c", "1", "-W", "1", data.Identifier("c1")) + }, + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), // Expect ping to fail with exit code 1 + }, + { + Description: "with enable_icc=true", + Require: nerdtest.CNIFirewallVersion("1.7.1"), + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + // Create a network with ICC enabled (default) + helpers.Ensure("network", "create", data.Identifier(), "--driver", "bridge", + "--opt", "com.docker.network.bridge.enable_icc=true") + + // Run a container in that network + data.Labels().Set("container1", helpers.Capture("run", "-d", "--net", data.Identifier(), + "--name", data.Identifier("c1"), testutil.CommonImage, "sleep", "infinity")) + // Wait for container to be running + nerdtest.EnsureContainerStarted(helpers, data.Identifier("c1")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Identifier("c1")) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Try to ping the other container in the same network + // This should succeed when ICC is enabled + return helpers.Command("run", "--rm", "--net", data.Identifier(), + testutil.CommonImage, "ping", "-c", "1", "-W", "1", data.Identifier("c1")) + }, + Expected: test.Expects(0, nil, nil), // Expect ping to succeed with exit code 0 + }, + { + Description: "with no enable_icc option set", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + // Create a network with ICC enabled (default) + helpers.Ensure("network", "create", data.Identifier(), "--driver", "bridge") + + // Run a container in that network + data.Labels().Set("container1", helpers.Capture("run", "-d", "--net", data.Identifier(), + "--name", data.Identifier("c1"), testutil.CommonImage, "sleep", "infinity")) + // Wait for container to be running + nerdtest.EnsureContainerStarted(helpers, data.Identifier("c1")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Identifier("c1")) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Try to ping the other container in the same network + // This should succeed when no ICC is set + return helpers.Command("run", "--rm", "--net", data.Identifier(), + testutil.CommonImage, "ping", "-c", "1", "-W", "1", data.Identifier("c1")) + }, + Expected: test.Expects(0, nil, nil), // Expect ping to succeed with exit code 0 + }, } testCase.Run(t) diff --git a/cmd/nerdctl/network/network_inspect_test.go b/cmd/nerdctl/network/network_inspect_test.go index ed4bb00d1e5..3b7e5276420 100644 --- a/cmd/nerdctl/network/network_inspect_test.go +++ b/cmd/nerdctl/network/network_inspect_test.go @@ -20,14 +20,17 @@ import ( "encoding/json" "errors" "os/exec" + "runtime" "strings" "testing" + "time" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/testutil" @@ -69,11 +72,11 @@ func TestNetworkInspect(t *testing.T) { Description: "none", Require: nerdtest.NerdctlNeedsFixing("no issue opened"), Command: test.Command("network", "inspect", "none"), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, "none") }), }, @@ -81,11 +84,11 @@ func TestNetworkInspect(t *testing.T) { Description: "host", Require: nerdtest.NerdctlNeedsFixing("no issue opened"), Command: test.Command("network", "inspect", "host"), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, "host") }), }, @@ -93,11 +96,11 @@ func TestNetworkInspect(t *testing.T) { Description: "bridge", Require: require.Not(require.Windows), Command: test.Command("network", "inspect", "bridge"), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, "bridge") }), }, @@ -105,11 +108,11 @@ func TestNetworkInspect(t *testing.T) { Description: "nat", Require: require.Windows, Command: test.Command("network", "inspect", "nat"), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, "nat") }), }, @@ -122,11 +125,11 @@ func TestNetworkInspect(t *testing.T) { helpers.Anyhow("network", "remove", "custom") }, Command: test.Command("network", "inspect", "custom"), - Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) { + Expected: test.Expects(0, nil, func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, "custom") }), }, @@ -139,11 +142,11 @@ func TestNetworkInspect(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, data.Labels().Get("basenet")) }, } @@ -160,11 +163,11 @@ func TestNetworkInspect(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, data.Labels().Get("basenet")) }, } @@ -188,11 +191,11 @@ func TestNetworkInspect(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, data.Labels().Get("netname")) }, } @@ -215,20 +218,20 @@ func TestNetworkInspect(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") got := dc[0] - assert.Equal(t, got.Name, data.Identifier(), info) - assert.Equal(t, got.Labels["tag"], "testNetwork", info) - assert.Equal(t, len(got.IPAM.Config), 1, info) - assert.Equal(t, got.IPAM.Config[0].Subnet, testSubnet, info) - assert.Equal(t, got.IPAM.Config[0].Gateway, testGateway, info) - assert.Equal(t, got.IPAM.Config[0].IPRange, testIPRange, info) + assert.Equal(t, got.Name, data.Identifier()) + assert.Equal(t, got.Labels["tag"], "testNetwork") + assert.Equal(t, len(got.IPAM.Config), 1) + assert.Equal(t, got.IPAM.Config[0].Subnet, testSubnet) + assert.Equal(t, got.IPAM.Config[0].Gateway, testGateway) + assert.Equal(t, got.IPAM.Config[0].IPRange, testIPRange) }, } }, @@ -248,7 +251,7 @@ func TestNetworkInspect(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { // Note: some functions need to be tested without the automatic --namespace nerdctl-test argument, so we need // to retrieve the binary name. // Note that we know this works already, so no need to assert err. @@ -289,6 +292,13 @@ func TestNetworkInspect(t *testing.T) { Setup: func(data test.Data, helpers test.Helpers) { helpers.Ensure("network", "create", data.Identifier("nginx-network-1")) helpers.Ensure("network", "create", data.Identifier("nginx-network-2")) + + // See https://github.com/containerd/nerdctl/issues/4322 + // Maybe network create on windows is asynchronous? + if runtime.GOOS == "windows" { + time.Sleep(time.Second) + } + helpers.Ensure("create", "--name", data.Identifier("nginx-container-1"), "--network", data.Identifier("nginx-network-1"), testutil.NginxAlpineImage) helpers.Ensure("create", "--name", data.Identifier("nginx-container-2"), "--network", data.Identifier("nginx-network-1"), testutil.NginxAlpineImage) helpers.Ensure("create", "--name", data.Identifier("nginx-container-on-diff-network"), "--network", data.Identifier("nginx-network-2"), testutil.NginxAlpineImage) @@ -307,11 +317,11 @@ func TestNetworkInspect(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Network err := json.Unmarshal([]byte(stdout), &dc) - assert.NilError(t, err, "Unable to unmarshal output\n"+info) - assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n"+info) + assert.NilError(t, err, "Unable to unmarshal output\n") + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") assert.Equal(t, dc[0].Name, data.Identifier("nginx-network-1")) // Assert only the "running" containers on the same network are returned. assert.Equal(t, 1, len(dc[0].Containers), "Expected a single container as per configuration, but got multiple.") @@ -320,6 +330,73 @@ func TestNetworkInspect(t *testing.T) { } }, }, + { + Description: "Display containers belonging to multiple networks in the output of nerdctl network inspect", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("network-1")) + helpers.Ensure("network", "create", data.Identifier("network-2")) + + // See https://github.com/containerd/nerdctl/issues/4322 + // Maybe network create on windows is asynchronous? + if runtime.GOOS == "windows" { + time.Sleep(time.Second) + } + + containerID := helpers.Capture("run", "-d", "--name", data.Identifier(), "--network", data.Identifier("network-1"), "--network", data.Identifier("network-2"), testutil.CommonImage, "sleep", nerdtest.Infinity) + + data.Labels().Set("containerID", strings.Trim(containerID, "\n")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("network", "remove", data.Identifier("network-1")) + helpers.Anyhow("network", "remove", data.Identifier("network-2")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier("network-1")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: expect.JSON([]dockercompat.Network{}, func(dc []dockercompat.Network, t tig.T) { + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") + assert.Equal(t, dc[0].Name, data.Identifier("network-1")) + assert.Equal(t, 1, len(dc[0].Containers), "Expected a single container as per configuration, but got multiple.") + assert.Equal(t, data.Identifier(), dc[0].Containers[data.Labels().Get("containerID")].Name) + }), + } + }, + }, + { + Description: "Display only containers attached to the specific network", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("some-network")) + helpers.Ensure("network", "create", data.Identifier("some-network-as-well")) + + // See https://github.com/containerd/nerdctl/issues/4322 + // Maybe network create on windows is asynchronous? + if runtime.GOOS == "windows" { + time.Sleep(time.Second) + } + + helpers.Ensure("run", "-d", "--name", data.Identifier(), "--network", data.Identifier("some-network-as-well"), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("network", "remove", data.Identifier("some-network")) + helpers.Anyhow("network", "remove", data.Identifier("some-network-as-well")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Identifier("some-network")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: expect.JSON([]dockercompat.Network{}, func(dc []dockercompat.Network, t tig.T) { + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results\n") + assert.Equal(t, dc[0].Name, data.Identifier("some-network")) + assert.Equal(t, 0, len(dc[0].Containers), "Expected no containers as per configuration, but got multiple.") + }), + } + }, + }, } testCase.Run(t) diff --git a/cmd/nerdctl/network/network_list_linux_test.go b/cmd/nerdctl/network/network_list_linux_test.go index 3bf6f9e912f..fbc374d6f4d 100644 --- a/cmd/nerdctl/network/network_list_linux_test.go +++ b/cmd/nerdctl/network/network_list_linux_test.go @@ -23,6 +23,7 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -52,16 +53,16 @@ func TestNetworkLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 1, info) + assert.Assert(t, len(lines) >= 1, "expected at least one line\n") netNames := map[string]struct{}{ data.Labels().Get("netID1")[:12]: {}, } for _, name := range lines { _, ok := netNames[name] - assert.Assert(t, ok, info) + assert.Assert(t, ok, "expected to find name\n") } }, } @@ -74,16 +75,38 @@ func TestNetworkLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 1, info) + assert.Assert(t, len(lines) >= 1, "expected at least one line\n") netNames := map[string]struct{}{ data.Labels().Get("netID2")[:12]: {}, } for _, name := range lines { _, ok := netNames[name] - assert.Assert(t, ok, info) + assert.Assert(t, ok, "expected to find name\n") + } + }, + } + }, + }, + { + Description: "filter name regexp", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "ls", "--quiet", "--filter", "name=.*"+data.Labels().Get("net2")+".*") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1) + netNames := map[string]struct{}{ + data.Labels().Get("netID2")[:12]: {}, + } + + for _, name := range lines { + _, ok := netNames[name] + assert.Assert(t, ok) } }, } diff --git a/cmd/nerdctl/network/network_remove_linux_test.go b/cmd/nerdctl/network/network_remove_linux_test.go index 7a86ec37962..8e640c7a482 100644 --- a/cmd/nerdctl/network/network_remove_linux_test.go +++ b/cmd/nerdctl/network/network_remove_linux_test.go @@ -24,6 +24,7 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -55,9 +56,9 @@ func TestNetworkRemove(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { _, err := netlink.LinkByName("br-" + data.Labels().Get("netID")[:12]) - assert.Error(t, err, "Link not found", info) + assert.Error(t, err, "Link not found") }, } }, @@ -96,9 +97,9 @@ func TestNetworkRemove(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { _, err := netlink.LinkByName("br-" + data.Labels().Get("netID")[:12]) - assert.Error(t, err, "Link not found", info) + assert.Error(t, err, "Link not found") }, } }, @@ -122,9 +123,9 @@ func TestNetworkRemove(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { _, err := netlink.LinkByName("br-" + data.Labels().Get("netID")[:12]) - assert.Error(t, err, "Link not found", info) + assert.Error(t, err, "Link not found") }, } }, diff --git a/cmd/nerdctl/search/search.go b/cmd/nerdctl/search/search.go new file mode 100644 index 00000000000..eb1b652d35d --- /dev/null +++ b/cmd/nerdctl/search/search.go @@ -0,0 +1,86 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package search + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/search" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "search [OPTIONS] TERM", + Short: "Search registry for images", + Args: cobra.ExactArgs(1), + RunE: runSearch, + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + + flags.Bool("no-trunc", false, "Don't truncate output") + flags.StringSliceP("filter", "f", nil, "Filter output based on conditions provided") + flags.Int("limit", 0, "Max number of search results") + flags.String("format", "", "Pretty-print search using a Go template") + + return cmd +} + +func processSearchFlags(cmd *cobra.Command) (types.SearchOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.SearchOptions{}, err + } + + noTrunc, err := cmd.Flags().GetBool("no-trunc") + if err != nil { + return types.SearchOptions{}, err + } + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return types.SearchOptions{}, err + } + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.SearchOptions{}, err + } + filter, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.SearchOptions{}, err + } + + return types.SearchOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + NoTrunc: noTrunc, + Limit: limit, + Filters: filter, + Format: format, + }, nil +} + +func runSearch(cmd *cobra.Command, args []string) error { + options, err := processSearchFlags(cmd) + if err != nil { + return err + } + + return search.Search(cmd.Context(), args[0], options) +} diff --git a/cmd/nerdctl/search/search_linux_test.go b/cmd/nerdctl/search/search_linux_test.go new file mode 100644 index 00000000000..76c318b7f38 --- /dev/null +++ b/cmd/nerdctl/search/search_linux_test.go @@ -0,0 +1,241 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package search + +import ( + "errors" + "regexp" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/test" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// All tests in this file are based on the output of `nerdctl search alpine`. +// +// Expected output format (default behavior with --limit 10): +// +// NAME DESCRIPTION STARS OFFICIAL +// alpine A minimal Docker image based on Alpine Linux… 11437 [OK] +// alpine/git A simple git container running in alpine li… 249 +// alpine/socat Run socat command in alpine container 115 +// alpine/helm Auto-trigger docker build for kubernetes hel… 69 +// alpine/curl 11 +// alpine/k8s Kubernetes toolbox for EKS (kubectl, helm, i… 64 +// alpine/bombardier Auto-trigger docker build for bombardier whe… 28 +// alpine/httpie Auto-trigger docker build for `httpie` when … 21 +// alpine/terragrunt Auto-trigger docker build for terragrunt whe… 18 +// alpine/openssl openssl 7 + +func TestSearch(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "basic-search", + Command: test.Command("search", "alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("STARS"), + expect.Contains("OFFICIAL"), + expect.Match(regexp.MustCompile(`NAME\s+DESCRIPTION\s+STARS\s+OFFICIAL`)), + expect.Contains("alpine"), + expect.Match(regexp.MustCompile(`alpine\s+A minimal Docker image based on Alpine Linux`)), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + expect.Contains("[OK]"), + expect.Match(regexp.MustCompile(`alpine/\w+`)), + ), + } + }, + }, + { + Description: "search-library-image", + Command: test.Command("search", "library/alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("STARS"), + expect.Contains("OFFICIAL"), + expect.Contains("alpine"), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + ), + } + }, + }, + { + Description: "search-with-no-trunc", + Command: test.Command("search", "alpine", "--limit", "3", "--no-trunc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("DESCRIPTION"), + expect.Contains("alpine"), + // With --no-trunc, the full description should be visible (not truncated with …) + expect.Match(regexp.MustCompile(`alpine\s+A minimal Docker image based on Alpine Linux with a complete package index and only 5 MB in size!`)), + ), + } + }, + }, + { + Description: "search-with-format", + Command: test.Command("search", "alpine", "--limit", "2", "--format", "{{.Name}}: {{.StarCount}}"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`alpine:\s*\d+`)), + expect.DoesNotContain("NAME"), + expect.DoesNotContain("DESCRIPTION"), + expect.DoesNotContain("OFFICIAL"), + ), + } + }, + }, + { + Description: "search-output-format", + Command: test.Command("search", "alpine", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`NAME\s+DESCRIPTION\s+STARS\s+OFFICIAL`)), + expect.Match(regexp.MustCompile(`(?m)^alpine\s+.*\s+\d+\s+\[OK\]\s*$`)), + expect.Match(regexp.MustCompile(`(?m)^alpine/\w+\s+.*\s+\d+\s*$`)), + expect.DoesNotMatch(regexp.MustCompile(`(?m)^\s+\d+\s*$`)), + ), + } + }, + }, + { + Description: "search-description-formatting", + Command: test.Command("search", "alpine", "--limit", "10"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Match(regexp.MustCompile(`Alpine Linux…`)), + expect.DoesNotMatch(regexp.MustCompile(`(?m)^\s+\d+\s+`)), + expect.Match(regexp.MustCompile(`(?m)^[a-z0-9/_-]+\s+.*\s+\d+`)), + ), + } + }, + }, + } + + testCase.Run(t) +} + +func TestSearchWithFilter(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "filter-is-official-true", + Command: test.Command("search", "alpine", "--filter", "is-official=true", "--limit", "5"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("OFFICIAL"), + expect.Contains("alpine"), + expect.Contains("[OK]"), + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d+\s+\[OK\]`)), + ), + } + }, + }, + { + Description: "filter-stars", + Command: test.Command("search", "alpine", "--filter", "stars=10000"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains("NAME"), + expect.Contains("STARS"), + expect.Contains("alpine"), + // The official alpine image has > 10000 stars + expect.Match(regexp.MustCompile(`alpine\s+.*\s+\d{4,}\s+\[OK\]`)), + ), + } + }, + }, + } + + testCase.Run(t) +} + +func TestSearchFilterErrors(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "invalid-filter-format", + Command: test.Command("search", "alpine", "--filter", "foo"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("bad format of filter (expected name=value)")}, + } + }, + }, + { + Description: "invalid-filter-key", + Command: test.Command("search", "alpine", "--filter", "foo=bar"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'foo'")}, + } + }, + }, + { + Description: "invalid-stars-value", + Command: test.Command("search", "alpine", "--filter", "stars=abc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'stars=abc'")}, + } + }, + }, + { + Description: "invalid-is-official-value", + Command: test.Command("search", "alpine", "--filter", "is-official=abc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("invalid filter 'is-official=abc'")}, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/search/search_test.go b/cmd/nerdctl/search/search_test.go new file mode 100644 index 00000000000..a76005fb94f --- /dev/null +++ b/cmd/nerdctl/search/search_test.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package search + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/system/system_info_test.go b/cmd/nerdctl/system/system_info_test.go index 8c4bfe10041..e5e4d6e5e88 100644 --- a/cmd/nerdctl/system/system_info_test.go +++ b/cmd/nerdctl/system/system_info_test.go @@ -27,6 +27,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/infoutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" @@ -34,12 +35,12 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) -func testInfoComparator(stdout string, info string, t *testing.T) { +func testInfoComparator(stdout string, t tig.T) { var dinf dockercompat.Info err := json.Unmarshal([]byte(stdout), &dinf) - assert.NilError(t, err, "failed to unmarshal stdout"+info) + assert.NilError(t, err, "failed to unmarshal stdout") unameM := infoutil.UnameM() - assert.Assert(t, dinf.Architecture == unameM, fmt.Sprintf("expected info.Architecture to be %q, got %q", unameM, dinf.Architecture)+info) + assert.Assert(t, dinf.Architecture == unameM, fmt.Sprintf("expected info.Architecture to be %q, got %q", unameM, dinf.Architecture)) } func TestInfo(t *testing.T) { diff --git a/cmd/nerdctl/system/system_prune_linux_test.go b/cmd/nerdctl/system/system_prune_linux_test.go index 70a4a9df651..7719ca7a373 100644 --- a/cmd/nerdctl/system/system_prune_linux_test.go +++ b/cmd/nerdctl/system/system_prune_linux_test.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -60,7 +61,7 @@ func TestSystemPrune(t *testing.T) { Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ ExitCode: 0, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { volumes := helpers.Capture("volume", "ls") networks := helpers.Capture("network", "ls") images := helpers.Capture("images") diff --git a/cmd/nerdctl/volume/volume_inspect_test.go b/cmd/nerdctl/volume/volume_inspect_test.go index b42b3d41558..8bd545003ea 100644 --- a/cmd/nerdctl/volume/volume_inspect_test.go +++ b/cmd/nerdctl/volume/volume_inspect_test.go @@ -99,10 +99,10 @@ func TestVolumeInspect(t *testing.T) { return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("vol1")), - expect.JSON([]native.Volume{}, func(dc []native.Volume, info string, t tig.T) { - assert.Assert(t, len(dc) == 1, fmt.Sprintf("one result, not %d", len(dc))+info) - assert.Assert(t, dc[0].Name == data.Labels().Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Labels().Get("vol1"), dc[0].Name)+info) - assert.Assert(t, dc[0].Labels == nil, fmt.Sprintf("expected labels to be nil and were %v", dc[0].Labels)+info) + expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { + assert.Assert(t, len(dc) == 1, fmt.Sprintf("one result, not %d", len(dc))) + assert.Assert(t, dc[0].Name == data.Labels().Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Labels().Get("vol1"), dc[0].Name)) + assert.Assert(t, dc[0].Labels == nil, fmt.Sprintf("expected labels to be nil and were %v", dc[0].Labels)) }), ), } @@ -117,7 +117,7 @@ func TestVolumeInspect(t *testing.T) { return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("vol2")), - expect.JSON([]native.Volume{}, func(dc []native.Volume, info string, t tig.T) { + expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { labels := *dc[0].Labels assert.Assert(t, len(labels) == 2, fmt.Sprintf("two results, not %d", len(labels))) assert.Assert(t, labels["foo"] == "fooval", fmt.Sprintf("label foo should be fooval, not %s", labels["foo"])) @@ -137,7 +137,7 @@ func TestVolumeInspect(t *testing.T) { return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("vol1")), - expect.JSON([]native.Volume{}, func(dc []native.Volume, info string, t tig.T) { + expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { assert.Assert(t, dc[0].Size == size, fmt.Sprintf("expected size to be %d (was %d)", size, dc[0].Size)) }), ), @@ -153,7 +153,7 @@ func TestVolumeInspect(t *testing.T) { return &test.Expected{ Output: expect.All( expect.Contains(data.Labels().Get("vol1"), data.Labels().Get("vol2")), - expect.JSON([]native.Volume{}, func(dc []native.Volume, info string, t tig.T) { + expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { assert.Assert(t, len(dc) == 2, fmt.Sprintf("two results, not %d", len(dc))) assert.Assert(t, dc[0].Name == data.Labels().Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Labels().Get("vol1"), dc[0].Name)) assert.Assert(t, dc[1].Name == data.Labels().Get("vol2"), fmt.Sprintf("expected name to be %q (was %q)", data.Labels().Get("vol2"), dc[1].Name)) @@ -173,7 +173,7 @@ func TestVolumeInspect(t *testing.T) { Errors: []error{errdefs.ErrNotFound, errdefs.ErrInvalidArgument}, Output: expect.All( expect.Contains(data.Labels().Get("vol1")), - expect.JSON([]native.Volume{}, func(dc []native.Volume, info string, t tig.T) { + expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { assert.Assert(t, len(dc) == 1, fmt.Sprintf("one result, not %d", len(dc))) assert.Assert(t, dc[0].Name == data.Labels().Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Labels().Get("vol1"), dc[0].Name)) }), diff --git a/cmd/nerdctl/volume/volume_list_test.go b/cmd/nerdctl/volume/volume_list_test.go index d666595abc6..22832e1d4ad 100644 --- a/cmd/nerdctl/volume/volume_list_test.go +++ b/cmd/nerdctl/volume/volume_list_test.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/tabutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -56,9 +57,9 @@ func TestVolumeLsSize(t *testing.T) { Command: test.Command("volume", "ls", "--size"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 4, "expected at least 4 lines"+info) + assert.Assert(t, len(lines) >= 4, "expected at least 4 lines") volSizes := map[string]string{ data.Identifier("1"): "100.0 KiB", data.Identifier("2"): "200.0 KiB", @@ -68,7 +69,7 @@ func TestVolumeLsSize(t *testing.T) { var numMatches = 0 var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") var err = tab.ParseHeader(lines[0]) - assert.NilError(t, err, info) + assert.NilError(t, err, "ParseHeader should not fail\n") for _, line := range lines { name, _ := tab.ReadRow(line, "VOLUME NAME") @@ -77,10 +78,10 @@ func TestVolumeLsSize(t *testing.T) { if !ok { continue } - assert.Assert(t, size == expectSize, fmt.Sprintf("expected size %s for volume %s, got %s", expectSize, name, size)+info) + assert.Assert(t, size == expectSize, fmt.Sprintf("expected size %s for volume %s, got %s", expectSize, name, size)) numMatches++ } - assert.Assert(t, numMatches == len(volSizes), fmt.Sprintf("expected %d volumes, got: %d", len(volSizes), numMatches)+info) + assert.Assert(t, numMatches == len(volSizes), fmt.Sprintf("expected %d volumes, got: %d", len(volSizes), numMatches)) }, } }, @@ -145,9 +146,9 @@ func TestVolumeLsFilter(t *testing.T) { Command: test.Command("volume", "ls", "--quiet"), Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 4, "expected at least 4 lines"+info) + assert.Assert(t, len(lines) >= 4, "expected at least 4 lines") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, data.Labels().Get("vol2"): {}, @@ -174,9 +175,9 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, data.Labels().Get("vol2"): {}, @@ -184,7 +185,7 @@ func TestVolumeLsFilter(t *testing.T) { } for _, name := range lines { _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -197,15 +198,15 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 1, "expected at least 1 lines"+info) + assert.Assert(t, len(lines) >= 1, "expected at least 1 lines") volNames := map[string]struct{}{ data.Labels().Get("vol2"): {}, } for _, name := range lines { _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -218,8 +219,8 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, strings.TrimSpace(stdout) == "", "expected no result"+info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.TrimSpace(stdout) == "", "expected no result") }, } }, @@ -231,8 +232,8 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, strings.TrimSpace(stdout) == "", "expected no result"+info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.TrimSpace(stdout) == "", "expected no result") }, } }, @@ -244,16 +245,16 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 2, "expected at least 2 lines"+info) + assert.Assert(t, len(lines) >= 2, "expected at least 2 lines") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, data.Labels().Get("vol2"): {}, } for _, name := range lines { _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -266,15 +267,36 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 1, "expected at least 1 line"+info) + assert.Assert(t, len(lines) >= 1, "expected at least 1 line") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, } for _, name := range lines { _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) + } + }, + } + }, + }, + { + Description: "Retrieving name=.*volume1.*", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "name=.*"+data.Labels().Get("vol1")+".*") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1, "expected at least 1 line") + volNames := map[string]struct{}{ + data.Labels().Get("vol1"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -282,23 +304,21 @@ func TestVolumeLsFilter(t *testing.T) { }, { Description: "Retrieving name=volume1 and name=volume2", - // Nerdctl filter behavior is broken - Require: nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3452"), Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { return helpers.Command("volume", "ls", "--quiet", "--filter", "name="+data.Labels().Get("vol1"), "--filter", "name="+data.Labels().Get("vol2")) }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 2, "expected at least 2 lines"+info) + assert.Assert(t, len(lines) >= 2, "expected at least 2 lines") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, data.Labels().Get("vol2"): {}, } for _, name := range lines { _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -312,9 +332,9 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines") volNames := map[string]struct{}{ data.Labels().Get("vol2"): {}, data.Labels().Get("vol4"): {}, @@ -329,7 +349,7 @@ func TestVolumeLsFilter(t *testing.T) { continue } _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -343,9 +363,9 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines") volNames := map[string]struct{}{ data.Labels().Get("vol2"): {}, data.Labels().Get("vol4"): {}, @@ -360,7 +380,7 @@ func TestVolumeLsFilter(t *testing.T) { continue } _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } @@ -374,9 +394,9 @@ func TestVolumeLsFilter(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var lines = strings.Split(strings.TrimSpace(stdout), "\n") - assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines") volNames := map[string]struct{}{ data.Labels().Get("vol1"): {}, data.Labels().Get("vol3"): {}, @@ -391,7 +411,7 @@ func TestVolumeLsFilter(t *testing.T) { continue } _, ok := volNames[name] - assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)) } }, } diff --git a/cmd/nerdctl/volume/volume_namespace_test.go b/cmd/nerdctl/volume/volume_namespace_test.go index 341d2d37204..e91bc17c12b 100644 --- a/cmd/nerdctl/volume/volume_namespace_test.go +++ b/cmd/nerdctl/volume/volume_namespace_test.go @@ -23,6 +23,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) @@ -76,7 +77,7 @@ func TestVolumeNamespace(t *testing.T) { return &test.Expected{ Output: expect.All( expect.DoesNotContain(data.Labels().Get("root_volume")), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { helpers.Ensure("--namespace", data.Labels().Get("root_namespace"), "volume", "inspect", data.Labels().Get("root_volume")) }, ), @@ -94,7 +95,7 @@ func TestVolumeNamespace(t *testing.T) { }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { return &test.Expected{ - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { helpers.Ensure("volume", "inspect", data.Labels().Get("root_volume")) helpers.Ensure("volume", "rm", data.Labels().Get("root_volume")) helpers.Ensure("--namespace", data.Labels().Get("root_namespace"), "volume", "inspect", data.Labels().Get("root_volume")) diff --git a/cmd/nerdctl/volume/volume_prune_linux_test.go b/cmd/nerdctl/volume/volume_prune_linux_test.go index 6565f578733..db81d1a1be4 100644 --- a/cmd/nerdctl/volume/volume_prune_linux_test.go +++ b/cmd/nerdctl/volume/volume_prune_linux_test.go @@ -22,6 +22,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" @@ -75,7 +76,7 @@ func TestVolumePrune(t *testing.T) { data.Labels().Get("namedBusy"), data.Labels().Get("namedDangling"), ), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { helpers.Ensure("volume", "inspect", data.Labels().Get("anonIDBusy")) helpers.Fail("volume", "inspect", data.Labels().Get("anonIDDangling")) helpers.Ensure("volume", "inspect", data.Labels().Get("namedBusy")) @@ -96,7 +97,7 @@ func TestVolumePrune(t *testing.T) { Output: expect.All( expect.DoesNotContain(data.Labels().Get("anonIDBusy"), data.Labels().Get("namedBusy")), expect.Contains(data.Labels().Get("anonIDDangling"), data.Labels().Get("namedDangling")), - func(stdout string, info string, t *testing.T) { + func(stdout string, t tig.T) { helpers.Ensure("volume", "inspect", data.Labels().Get("anonIDBusy")) helpers.Fail("volume", "inspect", data.Labels().Get("anonIDDangling")) helpers.Ensure("volume", "inspect", data.Labels().Get("namedBusy")) diff --git a/docs/command-reference.md b/docs/command-reference.md index f95d5db1b4a..e0d7b41f895 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -4,21 +4,21 @@ :nerd_face: = nerdctl specific -:blue_square: = Windows enabled - -Unlisted `docker` CLI flags are unimplemented yet in `nerdctl` CLI. -It does not necessarily mean that the corresponding features are missing in containerd. +> [!NOTE] +> - Unlisted `docker` CLI flags are unimplemented yet in `nerdctl` CLI. +> It does not necessarily mean that the corresponding features are missing in containerd. +> - Some commands and flags are only available on Linux. - [Container management](#container-management) - - [:whale: :blue_square: nerdctl run](#whale-blue_square-nerdctl-run) - - [:whale: :blue_square: nerdctl exec](#whale-blue_square-nerdctl-exec) - - [:whale: :blue_square: nerdctl create](#whale-blue_square-nerdctl-create) + - [:whale: nerdctl run](#whale-blue_square-nerdctl-run) + - [:whale: nerdctl exec](#whale-blue_square-nerdctl-exec) + - [:whale: nerdctl create](#whale-blue_square-nerdctl-create) - [:whale: nerdctl cp](#whale-nerdctl-cp) - - [:whale: :blue_square: nerdctl ps](#whale-blue_square-nerdctl-ps) - - [:whale: :blue_square: nerdctl inspect](#whale-blue_square-nerdctl-inspect) + - [:whale: nerdctl ps](#whale-blue_square-nerdctl-ps) + - [:whale: nerdctl inspect](#whale-blue_square-nerdctl-inspect) - [:whale: nerdctl logs](#whale-nerdctl-logs) - [:whale: nerdctl port](#whale-nerdctl-port) - [:whale: nerdctl rm](#whale-nerdctl-rm) @@ -34,15 +34,17 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl attach](#whale-nerdctl-attach) - [:whale: nerdctl container prune](#whale-nerdctl-container-prune) - [:whale: nerdctl diff](#whale-nerdctl-diff) + - [:whale: nerdctl export](#whale-nerdctl-export) - [Build](#build) - [:whale: nerdctl build](#whale-nerdctl-build) - [:whale: nerdctl commit](#whale-nerdctl-commit) - [Image management](#image-management) - - [:whale: :blue_square: nerdctl images](#whale-blue_square-nerdctl-images) - - [:whale: :blue_square: nerdctl pull](#whale-blue_square-nerdctl-pull) + - [:whale: nerdctl images](#whale-blue_square-nerdctl-images) + - [:whale: nerdctl pull](#whale-blue_square-nerdctl-pull) - [:whale: nerdctl push](#whale-nerdctl-push) - [:whale: nerdctl load](#whale-nerdctl-load) - [:whale: nerdctl save](#whale-nerdctl-save) + - [:whale: nerdctl import](#whale-nerdctl-import) - [:whale: nerdctl tag](#whale-nerdctl-tag) - [:whale: nerdctl rmi](#whale-nerdctl-rmi) - [:whale: nerdctl image inspect](#whale-nerdctl-image-inspect) @@ -51,9 +53,20 @@ It does not necessarily mean that the corresponding features are missing in cont - [:nerd_face: nerdctl image convert](#nerd_face-nerdctl-image-convert) - [:nerd_face: nerdctl image encrypt](#nerd_face-nerdctl-image-encrypt) - [:nerd_face: nerdctl image decrypt](#nerd_face-nerdctl-image-decrypt) +- [Checkpoint management](#checkpoint-management) + - [:whale: nerdctl checkpoint create](#whale-nerdctl-checkpoint-create) + - [:whale: nerdctl checkpoint list](#whale-nerdctl-checkpoint-list) + - [:whale: nerdctl checkpoint remove](#whale-nerdctl-checkpoint-remove) +- [Manifest management](#manifest-management) + - [:whale: nerdctl manifest annotate](#whale-nerdctl-manifest-annotate) + - [:whale: nerdctl manifest create](#whale-nerdctl-manifest-create) + - [:whale: nerdctl manifest inspect](#whale-nerdctl-manifest-inspect) + - [:whale: nerdctl manifest push](#whale-nerdctl-manifest-push) + - [:whale: nerdctl manifest rm](#whale-nerdctl-manifest-rm) - [Registry](#registry) - [:whale: nerdctl login](#whale-nerdctl-login) - [:whale: nerdctl logout](#whale-nerdctl-logout) + - [:whale: nerdctl search](#whale-nerdctl-search) - [Network management](#network-management) - [:whale: nerdctl network create](#whale-nerdctl-network-create) - [:whale: nerdctl network ls](#whale-nerdctl-network-ls) @@ -67,11 +80,11 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl volume rm](#whale-nerdctl-volume-rm) - [:whale: nerdctl volume prune](#whale-nerdctl-volume-prune) - [Namespace management](#namespace-management) - - [:nerd_face: :blue_square: nerdctl namespace create](#nerd_face-blue_square-nerdctl-namespace-create) - - [:nerd_face: :blue_square: nerdctl namespace inspect](#nerd_face-blue_square-nerdctl-namespace-inspect) - - [:nerd_face: :blue_square: nerdctl namespace ls](#nerd_face-blue_square-nerdctl-namespace-ls) - - [:nerd_face: :blue_square: nerdctl namespace remove](#nerd_face-blue_square-nerdctl-namespace-remove) - - [:nerd_face: :blue_square: nerdctl namespace update](#nerd_face-blue_square-nerdctl-namespace-update) + - [:nerd_face: nerdctl namespace create](#nerd_face-nerdctl-namespace-create) + - [:nerd_face: nerdctl namespace inspect](#nerd_face-nerdctl-namespace-inspect) + - [:nerd_face: nerdctl namespace ls](#nerd_face-nerdctl-namespace-ls) + - [:nerd_face: nerdctl namespace remove](#nerd_face-nerdctl-namespace-remove) + - [:nerd_face: nerdctl namespace update](#nerd_face-nerdctl-namespace-update) - [AppArmor profile management](#apparmor-profile-management) - [:nerd_face: nerdctl apparmor inspect](#nerd_face-nerdctl-apparmor-inspect) - [:nerd_face: nerdctl apparmor load](#nerd_face-nerdctl-apparmor-load) @@ -127,7 +140,7 @@ It does not necessarily mean that the corresponding features are missing in cont ## Container management -### :whale: :blue_square: nerdctl run +### :whale: nerdctl run Run a command in a new container. @@ -139,11 +152,11 @@ Usage: `nerdctl run [OPTIONS] IMAGE [COMMAND] [ARG...]` Basic flags: - :whale: `-a, --attach`: Attach STDIN, STDOUT, or STDERR -- :whale: :blue_square: `-i, --interactive`: Keep STDIN open even if not attached" -- :whale: :blue_square: `-t, --tty`: Allocate a pseudo-TTY +- :whale: `-i, --interactive`: Keep STDIN open even if not attached" +- :whale: `-t, --tty`: Allocate a pseudo-TTY - :warning: WIP: currently `-t` conflicts with `-d` - :whale: `-sig-proxy`: Proxy received signals to the process (default true) -- :whale: :blue_square: `-d, --detach`: Run container in background and print container ID +- :whale: `-d, --detach`: Run container in background and print container ID - :whale: `--restart=(no|always|on-failure|unless-stopped)`: Restart policy to apply when a container exits - Default: "no" - always: Always restart the container if it stops. @@ -172,7 +185,7 @@ Init process flags: Isolation flags: -- :whale: :blue_square: :nerd_face: `--isolation=(default|process|host|hyperv)`: Used on Windows to change process isolation level. `default` will use the runtime options configured in `default_runtime` in the [containerd configuration](https://github.com/containerd/containerd/blob/master/docs/cri/config.md#cri-plugin-config-guide) which is `process` in containerd by default. `process` runs process isolated containers. `host` runs [Host Process containers](https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/). Host process containers inherit permissions from containerd process unless `--user` is specified then will start with user specified and the user specified must be present on the host. `host` requires Containerd 1.7+. `hyperv` runs Hyper-V hypervisor partition-based isolated containers. Not implemented for Linux. +- :whale: :nerd_face: `--isolation=(default|process|host|hyperv)`: Used on Windows to change process isolation level. `default` will use the runtime options configured in `default_runtime` in the [containerd configuration](https://github.com/containerd/containerd/blob/master/docs/cri/config.md#cri-plugin-config-guide) which is `process` in containerd by default. `process` runs process isolated containers. `host` runs [Host Process containers](https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/). Host process containers inherit permissions from containerd process unless `--user` is specified then will start with user specified and the user specified must be present on the host. `host` requires Containerd 1.7+. `hyperv` runs Hyper-V hypervisor partition-based isolated containers. Not implemented for Linux. Network flags: @@ -223,7 +236,7 @@ Resource flags: - :whale: `--cgroupns=(host|private)`: Cgroup namespace to use - Default: "private" on cgroup v2 hosts, "host" on cgroup v1 hosts - :whale: `--cgroup-parent`: Optional parent cgroup for the container -- :whale: :blue_square: `--device`: Add a host device to the container +- :whale: `--device`: Add a host device to the container Intel RDT flags: @@ -231,7 +244,7 @@ Intel RDT flags: User flags: -- :whale: :blue_square: `-u, --user`: Username or UID (format: [:]) +- :whale: `-u, --user`: Username or UID (format: [:]) - :nerd_face: `--umask`: Set the umask inside the container. Defaults to 0022. Corresponds to Podman CLI. - :whale: `--group-add`: Add additional groups to join @@ -244,6 +257,7 @@ Security flags: - :whale: `--security-opt apparmor=`: specify custom AppArmor profile - :whale: `--security-opt no-new-privileges`: disallow privilege escalation, e.g., setuid and file capabilities - :whale: `--security-opt systempaths=unconfined`: Turn off confinement for system paths (masked paths, read-only paths) for the container +- :whale: `--security-opt writable-cgroups`: making the cgroups writeable - :nerd_face: `--security-opt privileged-without-host-devices`: Don't pass host devices to privileged containers - :whale: `--cap-add=`: Add Linux capabilities - :whale: `--cap-drop=`: Drop Linux capabilities @@ -265,7 +279,7 @@ Runtime flags: Volume flags: -- :whale: :blue_square: `-v, --volume :[:]`: Bind mount a volume, e.g., `-v /mnt:/mnt:rro,rprivate` +- :whale: `-v, --volume :[:]`: Bind mount a volume, e.g., `-v /mnt:/mnt:rro,rprivate` - :whale: option `rw` : Read/Write (when writable) - :whale: option `ro` : Non-recursive read-only - :nerd_face: option `rro`: Recursive read-only. Should be used in conjunction with `rprivate`. e.g., `-v /mnt:/mnt:rro,rprivate` makes children such as `/mnt/usb` to be read-only, too. @@ -306,20 +320,30 @@ Rootfs flags: Env flags: -- :whale: :blue_square: `--entrypoint`: Overwrite the default ENTRYPOINT of the image -- :whale: :blue_square: `-w, --workdir`: Working directory inside the container -- :whale: :blue_square: `-e, --env`: Set environment variables -- :whale: :blue_square: `--env-file`: Set environment variables from file +- :whale: `--entrypoint`: Overwrite the default ENTRYPOINT of the image +- :whale: `-w, --workdir`: Working directory inside the container +- :whale: `-e, --env`: Set environment variables +- :whale: `--env-file`: Set environment variables from file Metadata flags: -- :whale: :blue_square: `--name`: Assign a name to the container -- :whale: :blue_square: `-l, --label`: Set meta data on a container (Not passed through the OCI runtime since nerdctl v2.0, with an exception for `nerdctl/bypass4netns`) -- :whale: :blue_square: `--label-file`: Read in a line delimited file of labels -- :whale: :blue_square: `--annotation`: Add an annotation to the container (passed through to the OCI runtime) -- :whale: :blue_square: `--cidfile`: Write the container ID to the file +- :whale: `--name`: Assign a name to the container +- :whale: `-l, --label`: Set meta data on a container (Not passed through the OCI runtime since nerdctl v2.0, with an exception for `nerdctl/bypass4netns`) +- :whale: `--label-file`: Read in a line delimited file of labels +- :whale: `--annotation`: Add an annotation to the container (passed through to the OCI runtime) +- :whale: `--cidfile`: Write the container ID to the file - :nerd_face: `--pidfile`: file path to write the task's pid. The CLI syntax conforms to Podman convention. +Health check flags: + +- :whale: `--health-cmd`: Command to run to check container health +- :whale: `--health-interval`: Time between running the check (e.g., 30s, 1m) +- :whale: `--health-timeout`: Time to wait before considering the check failed (e.g., 5s) +- :whale: `--health-retries`: Number of failures before container is considered unhealthy +- :whale: `--health-start-period`: Start period for the container to initialize before starting health-retries countdown +- :whale: `--health-start-interval`: Interval between checks during the start period +- :whale: `--no-healthcheck`: Disable any health checks defined by image or CLI + Logging flags: - :whale: `--log-driver=(json-file|journald|fluentd|syslog|none)`: Logging driver for the container (default `json-file`). @@ -427,10 +451,10 @@ IPFS flags: - :nerd_face: `--ipfs-address`: Multiaddr of IPFS API (default uses `$IPFS_PATH` env variable if defined or local directory `~/.ipfs`) Unimplemented `docker run` flags: - `--device-cgroup-rule`, `--disable-content-trust`, `--expose`, `--health-*`, `--isolation`, `--no-healthcheck`, + `--device-cgroup-rule`, `--disable-content-trust`, `--expose`, `--isolation`, `--link*`, `--publish-all`, `--storage-opt`, `--volume-driver` -### :whale: :blue_square: nerdctl exec +### :whale: nerdctl exec Run a command in a running container. @@ -450,7 +474,7 @@ Flags: Unimplemented `docker exec` flags: `--detach-keys` -### :whale: :blue_square: nerdctl create +### :whale: nerdctl create Create a new container. @@ -479,7 +503,7 @@ Flags: Unimplemented `docker cp` flags: `--archive` -### :whale: :blue_square: nerdctl ps +### :whale: nerdctl ps List containers. @@ -523,7 +547,7 @@ Following arguments for `--filter` are not supported yet: 4. `--filter isolation=` 5. `--filter is-task=` -### :whale: :blue_square: nerdctl inspect +### :whale: nerdctl inspect Display detailed information on one or more containers. @@ -536,8 +560,6 @@ Flags: - :whale: `--type`: Return JSON for specified type - :whale: `--size`: Display total file sizes if the type is container -Unimplemented `docker inspect` flags: `--size` - ### :whale: nerdctl logs Fetch the logs of a container. @@ -596,8 +618,10 @@ Flags: - :whale: `-a, --attach`: Attach STDOUT/STDERR and forward signals - :whale: `--detach-keys`: Override the default detach keys +- :whale: `--checkpoint`: checkpoint name +- :whale: `--detach-keys`: checkpoint directory -Unimplemented `docker start` flags: `--checkpoint`, `--checkpoint-dir`, `--interactive` +Unimplemented `docker start` flags: `--interactive` ### :whale: nerdctl restart @@ -686,8 +710,9 @@ Usage: `nerdctl attach CONTAINER` Flags: - :whale: `--detach-keys`: Override the default detach keys +- :whale: `--no-stdin`: Do not attach STDIN -Unimplemented `docker attach` flags: `--no-stdin`, `--sig-proxy` +Unimplemented `docker attach` flags: `--sig-proxy` ### :whale: nerdctl container prune @@ -707,6 +732,12 @@ Inspect changes to files or directories on a container's filesystem Usage: `nerdctl diff CONTAINER` +### :whale: nerdctl export + +Export a containers filesystem as a tar archive. + +Usage: `nerdctl export CONTAINER` + ## Build ### :whale: nerdctl build @@ -764,10 +795,20 @@ Flags: - :whale: `-m, --message`: Commit message - :whale: `-c, --change`: Apply Dockerfile instruction to the created image (supported directives: [CMD, ENTRYPOINT]) - :whale: `-p, --pause`: Pause container during commit (default: true) +- :nerd_face: `--compression`: Commit compression algorithm (supported values: zstd or gzip) (default: gzip) (zstd is generally better for compression ratio but might not be as widely supported) +- :nerd_face: `--format`: Format of the committed image (supported values: docker or oci) (default: docker) (docker uses Docker Schema2 media types for compatibility, oci uses OCI image format media types) +- :nerd_face: `--estargz`: Convert the committed layer to eStargz for lazy pulling +- :nerd_face: `--estargz-compression-level`: eStargz compression level (1-9) (default: 9) +- :nerd_face: `--estargz-chunk-size`: eStargz chunk size +- :nerd_face: `--estargz-min-chunk-size`: The minimal number of bytes of data must be written in one gzip stream +- :nerd_face: `--zstdchunked`: Convert the committed layer to zstd:chunked for lazy pulling +support zstdchunked convert +- :nerd_face: `--zstdchunked-compression-level`: zstd:chunked compression level (default: 3) +- :nerd_face: `--zstdchunked-chunk-size`: zstd:chunked chunk size ## Image management -### :whale: :blue_square: nerdctl images +### :whale: nerdctl images List images @@ -794,7 +835,7 @@ Flags: - :nerd_face: `--filter=reference=`: Filter images by reference (Matches both docker compatible wildcard pattern and regexp match) - :nerd_face: `--names`: Show image names -### :whale: :blue_square: nerdctl pull +### :whale: nerdctl pull Pull an image from a registry. @@ -872,6 +913,19 @@ Flags: - :nerd_face: `--platform=(amd64|arm64|...)`: Export content for a specific platform - :nerd_face: `--all-platforms`: Export content for all platforms +### :whale: nerdctl import + +Import the contents from a tarball to create a filesystem image. + +Usage: `nerdctl import [OPTIONS] file|URL|- [REPOSITORY[:TAG]]` + +Flags: + +- :whale: `-m, --message`: Set commit message for imported image +- :nerd_face: `--platform=(linux/amd64|linux/arm64|...)`: Set platform for the imported image + +Unimplemented `docker import` flags: `--change` + ### :whale: nerdctl tag Create a tag TARGET\_IMAGE that refers to SOURCE\_IMAGE. @@ -925,7 +979,7 @@ Usage: `nerdctl image prune [OPTIONS]` Flags: - :whale: `-a, --all`: Remove all unused images, not just dangling ones -- :whale: `-f, --filter`: Filter the images. +- :whale: `--filter`: Filter the images. - :whale: `--filter=until=`: Images created before given date formatted timestamps or Go duration strings. Currently does not support Unix timestamps. - :whale: `--filter=label=`: Matches images based on the presence of a label alone or a label and a value - :whale: `-f, --force`: Do not prompt for confirmation @@ -957,6 +1011,11 @@ Flags: - `--oci` : convert Docker media types to OCI media types - `--platform=` : convert content for a specific platform - `--all-platforms` : convert content for all platforms (default: false) +- `--soci` : convert content to SOCI image manifest v2 +*[**Note**: soci convert uses the default platform if nothing is specified. --platform flag can be used to specify a platform]* +- `--soci-span-size` : Span size in bytes that soci index uses to segment layer data. Default is 4 MiB. +- `--soci-min-layer-size`: Minimum layer size in bytes to build zTOC for. Smaller layers won't have zTOC and not lazy pulled. Default is 10 MiB. + ### :nerd_face: nerdctl image encrypt @@ -1008,6 +1067,129 @@ Flags: - `--platform=` : Convert content for a specific platform - `--all-platforms` : Convert content for all platforms (default: false) +## Checkpoint management + +### :whale: nerdctl checkpoint create + +Create a checkpoint from a running container. + +Usage: `nerdctl checkpoint create [OPTIONS] CONTAINER CHECKPOINT` + +Flags: +- :whale: `--leave-running`: Leave the container running after checkpoint +- :whale: `checkpoint-dir`: Use a custom checkpoint storage directory + +### :whale: nerdctl checkpoint list + +List checkpoints for a container + +Usage: `nerdctl checkpoint list/ls [OPTIONS] CONTAINER` + +Flags: +- :whale: `checkpoint-dir`: Use a custom checkpoint storage directory + +### :whale: nerdctl checkpoint remove + +Remove a checkpoint for a container + +Usage: `nerdctl checkpoint remove/rm [OPTIONS] CONTAINER CHECKPOINT` + +Flags: +- :whale: `checkpoint-dir`: Use a custom checkpoint storage directory + +## Manifest management + +### :whale: nerdctl manifest annotate + +Add additional information to a local image manifest. + +Usage: `nerdctl manifest annotate [OPTIONS] INDEX/MANIFESTLIST MANIFEST` + +Flags: + +- :whale: `--os`: Set operating system (e.g., "linux", "windows", "freebsd") +- :whale: `--arch`: Set architecture (e.g., "amd64", "arm64", "arm") +- :whale: `--os-version`: Set operating system version (e.g., "10.0.19041") +- :whale: `--variant`: Set architecture variant (e.g., "v7", "v8") +- :whale: `--os-features`: Set operating system features (e.g., "win32k") + +Examples: + +```bash +nerdctl manifest annotate myapp:latest alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f \ + --os linux --arch arm --variant v7 --os-features feature1,feature2 +``` + +### :whale: nerdctl manifest create + +Create a local index/manifest list. + +Usage: `nerdctl manifest create [OPTIONS] INDEX/MANIFESTLIST MANIFEST [MANIFEST...]` + +Flags: + +- `--amend`: Amend the existing index/manifest list +- `--insecure`: Allow communication with an insecure registry + +Example: + +```bash +nerdctl manifest create myapp:latest alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f +``` + +### :whale: nerdctl manifest inspect + +Display the contents of a manifest list or manifest. + +Usage: `nerdctl manifest inspect [OPTIONS] MANIFEST` + +#### Input formats + +You can specify the manifest to inspect using one of the following formats: +- **Image name with tag**: `alpine:3.22.1` +- **Image name with digest**: `alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f` + +Flags: + +- `--verbose` : Verbose output, show additional info including layers and platform +- `--insecure`: Allow communication with an insecure registry +Example: + +```bash +nerdctl manifest inspect alpine:3.22.1 +nerdctl manifest inspect alpine@sha256:eafc1edb577d2e9b458664a15f23ea1c370214193226069eb22921169fc7e43f +``` + +### :whale: nerdctl manifest push + +Push a manifest list to a registry. + +Usage: `nerdctl manifest push [OPTIONS] INDEX/MANIFESTLIST` + +Flags: + +- `--insecure`: Allow communication with an insecure registry +- `--purge`: Remove the manifest list after pushing + +Examples: + +```bash +# Push a manifest list to a registry +nerdctl manifest push myapp:latest +``` + +### :whale: nerdctl manifest rm + +Remove one or more index/manifest lists. + +Usage: `nerdctl manifest rm INDEX/MANIFESTLIST [INDEX/MANIFESTLIST...]` + +Example: + +```bash +nerdctl manifest rm alpine:3.22.1 alpine:3.22.2 +``` + ## Registry ### :whale: nerdctl login @@ -1028,6 +1210,19 @@ Log out from a container registry Usage: `nerdctl logout [SERVER]` +### :whale: nerdctl search + +Search Docker Hub or a registry for images + +Usage: `nerdctl search [OPTIONS] TERM` + +Flags: + +- :whale: `--limit`: Max number of search results (default: 0) +- :whale: `--no-trunc`: Don't truncate output (default: false) +- :whale: `--filter, -f`: Filter output based on conditions provided +- :whale: `--format`: Format the output using the given Go template + ## Network management ### :whale: nerdctl network create @@ -1044,16 +1239,18 @@ Flags: - :whale: `--driver=bridge`: Default driver for unix - :whale: `--driver=macvlan`: Macvlan network driver for unix - :whale: `--driver=ipvlan`: IPvlan network driver for unix - - :whale: :blue_square: `--driver=nat`: Default driver for windows + - :whale: `--driver=nat`: Default driver for windows - :whale: `-o, --opt`: Set driver specific options - :whale: `--opt=com.docker.network.driver.mtu=`: Set the containers network MTU - :nerd_face: `--opt=mtu=`: Alias of `--opt=com.docker.network.driver.mtu=` + - :whale: `--opt=com.docker.network.bridge.enable_icc=`: Enable or Disable inter-container connectivity + - :nerd_face: `--opt=icc=`: Alias of `--opt=com.docker.network.bridge.enable_icc` - :whale: `--opt=macvlan_mode=(bridge)>`: Set macvlan network mode (default: bridge) - :whale: `--opt=ipvlan_mode=(l2|l3)`: Set IPvlan network mode (default: l2) - :nerd_face: `--opt=mode=(bridge|l2|l3)`: Alias of `--opt=macvlan_mode=(bridge)` and `--opt=ipvlan_mode=(l2|l3)` - :whale: `--opt=parent=`: Set valid parent interface on host - :whale: `--ipam-driver=(default|host-local|dhcp)`: IP Address Management Driver - - :whale: :blue_square: `--ipam-driver=default`: Default IPAM driver + - :whale: `--ipam-driver=default`: Default IPAM driver - :nerd_face: `--ipam-driver=host-local`: Host-local IPAM driver for unix - :nerd_face: `--ipam-driver=dhcp`: DHCP IPAM driver for unix, requires root - :whale: `--ipam-opt`: Set IPAM driver specific options @@ -1062,8 +1259,9 @@ Flags: - :whale: `--ip-range`: Allocate container ip from a sub-range - :whale: `--label`: Set metadata on a network - :whale: `--ipv6`: Enable IPv6. Should be used with a valid subnet. +- :whale: `--internal`: Restrict external access to the network. -Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--internal`, `--scope` +Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--scope` ### :whale: nerdctl network ls @@ -1193,7 +1391,7 @@ Unimplemented `docker volume prune` flags: `--filter` ## Namespace management -### :nerd_face: :blue_square: nerdctl namespace create +### :nerd_face: nerdctl namespace create Create a new namespace. @@ -1202,13 +1400,13 @@ Flags: - `--label`: Set labels for a namespace -### :nerd_face: :blue_square: nerdctl namespace inspect +### :nerd_face: nerdctl namespace inspect Inspect a namespace. Usage: `nerdctl namespace inspect NAMESPACE` -### :nerd_face: :blue_square: nerdctl namespace ls +### :nerd_face: nerdctl namespace ls List containerd namespaces such as "default", "moby", or "k8s.io". @@ -1217,8 +1415,9 @@ Usage: `nerdctl namespace ls [OPTIONS]` Flags: - `-q, --quiet`: Only display namespace names +- `-f, --format`: Format the output using the given Go template, e.g, `{{json .}}` -### :nerd_face: :blue_square: nerdctl namespace remove +### :nerd_face: nerdctl namespace remove Remove one or more namespaces. @@ -1228,7 +1427,7 @@ Flags: - `-c, --cgroup`: delete the namespace's cgroup -### :nerd_face: :blue_square: nerdctl namespace update +### :nerd_face: nerdctl namespace update Update labels for a namespace. @@ -1452,7 +1651,7 @@ Flags: - :whale: `--pull`: Pull image before running ("always"|"missing"|"never") Unimplemented `docker-compose up` (V1) flags: `--no-deps`, `--always-recreate-deps`, -`--no-start`, `--abort-on-container-exit`, `--attach-dependencies`, `--timeout`, `--renew-anon-volumes`, `--exit-code-from` +`--no-start`, `--attach-dependencies`, `--timeout`, `--renew-anon-volumes`, `--exit-code-from` Unimplemented `docker compose up` (V2) flags: `--environment` @@ -1759,15 +1958,15 @@ Flags: ## Global flags -- :nerd_face: :blue_square: `--address`: containerd address, optionally with "unix://" prefix -- :nerd_face: :blue_square: `-a`, `--host`, `-H`: deprecated aliases of `--address` -- :nerd_face: :blue_square: `--namespace`: containerd namespace -- :nerd_face: :blue_square: `-n`: deprecated alias of `--namespace` -- :nerd_face: :blue_square: `--snapshotter`: containerd snapshotter -- :nerd_face: :blue_square: `--storage-driver`: deprecated alias of `--snapshotter` -- :nerd_face: :blue_square: `--cni-path`: CNI binary path (default: `/opt/cni/bin`) [`$CNI_PATH`] -- :nerd_face: :blue_square: `--cni-netconfpath`: CNI netconf path (default: `/etc/cni/net.d`) [`$NETCONFPATH`] -- :nerd_face: :blue_square: `--data-root`: nerdctl data root, e.g. "/var/lib/nerdctl" +- :nerd_face: `--address`: containerd address, optionally with "unix://" prefix +- :nerd_face: `-a`, `--host`, `-H`: deprecated aliases of `--address` +- :nerd_face: `--namespace`: containerd namespace +- :nerd_face: `-n`: deprecated alias of `--namespace` +- :nerd_face: `--snapshotter`: containerd snapshotter +- :nerd_face: `--storage-driver`: deprecated alias of `--snapshotter` +- :nerd_face: `--cni-path`: CNI binary path (default: `/opt/cni/bin`) [`$CNI_PATH`] +- :nerd_face: `--cni-netconfpath`: CNI netconf path (default: `/etc/cni/net.d`) [`$NETCONFPATH`] +- :nerd_face: `--data-root`: nerdctl data root, e.g. "/var/lib/nerdctl" - :nerd_face: `--cgroup-manager=(cgroupfs|systemd|none)`: cgroup manager - Default: "systemd" on cgroup v2 (rootful & rootless), "cgroupfs" on v1 rootful, "none" on v1 rootless - :nerd_face: `--insecure-registry`: skips verifying HTTPS certs, and allows falling back to plain HTTP @@ -1783,23 +1982,16 @@ See [`./config.md`](./config.md). Container management: - `docker diff` -- `docker checkpoint *` Image: -- `docker export` and `docker import` - `docker trust *` (Instead, nerdctl supports `nerdctl pull --verify=cosign|notation` and `nerdctl push --sign=cosign|notation`. See [`./cosign.md`](./cosign.md) and [`./notation.md`](./notation.md).) -- `docker manifest *` Network management: - `docker network connect` - `docker network disconnect` -Registry: - -- `docker search` - Compose: - `docker-compose events|scale` diff --git a/docs/config.md b/docs/config.md index 9d9369e2ebe..4ed70965948 100644 --- a/docs/config.md +++ b/docs/config.md @@ -27,11 +27,14 @@ cgroup_manager = "cgroupfs" hosts_dir = ["/etc/containerd/certs.d", "/etc/docker/certs.d"] experimental = true userns_remap = "" +dns = ["8.8.8.8", "1.1.1.1"] +dns_opts = ["ndots:1", "timeout:2"] +dns_search = ["example.com", "example.org"] ``` ## Properties -| TOML property | CLI flag | Env var | Description | Availability \*1 | +| TOML property | CLI flag | Env var | Description | Availability | |---------------------|------------------------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------| | `debug` | `--debug` | | Debug mode | Since 0.16.0 | | `debug_full` | `--debug-full` | | Debug mode (with full output) | Since 0.16.0 | @@ -50,6 +53,9 @@ userns_remap = "" | `kube_hide_dupe` | `--kube-hide-dupe` | | Deduplicate images for Kubernetes with namespace k8s.io, no more redundant ones are displayed | Since 2.0.3 | | `cdi_spec_dirs` | `--cdi-spec-dirs` | | The folders to use when searching for CDI ([container-device-interface](https://github.com/cncf-tags/container-device-interface)) specifications. | Since 2.1.0 | | `userns_remap` | `--userns-remap` | | Support idmapping of containers. This options is only supported on rootful linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively. | Since 2.1.0 | +| `dns` | | | Set global DNS servers for containers | Since 2.1.3 | +| `dns_opts` | | | Set global DNS options for containers | Since 2.1.3 | +| `dns_search` | | | Set global DNS search domains for containers | Since 2.1.3 | The properties are parsed in the following precedence: 1. CLI flag @@ -57,7 +63,6 @@ The properties are parsed in the following precedence: 3. TOML property 4. Built-in default value (Run `nerdctl --help` to see the default values) -\*1: Availability of the TOML properties ## See also - [`registry.md`](registry.md) diff --git a/docs/dev/auditing_dockerfile.md b/docs/dev/auditing_dockerfile.md index 39fd518a1b0..81a57592e53 100644 --- a/docs/dev/auditing_dockerfile.md +++ b/docs/dev/auditing_dockerfile.md @@ -34,7 +34,7 @@ is the local ip of the Charles proxy (non-localhost) Add the following stages in the dockerfile: ```dockerfile -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bookworm AS hack-build-base-debian +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-trixie AS hack-build-base-debian RUN apt-get update -qq; apt-get -qq install ca-certificates COPY charles-ssl-proxying-certificate.crt /usr/local/share/ca-certificates/ RUN update-ca-certificates @@ -52,7 +52,7 @@ RUN update-ca-certificates Then replace any later "FROM" with our modified bases: ``` -golang:${GO_VERSION}-bookworm => hack-build-base-debian +golang:${GO_VERSION}-trixie => hack-build-base-debian golang:${GO_VERSION}-alpine => hack-build-base ubuntu:${UBUNTU_VERSION} => hack-base ``` @@ -103,9 +103,7 @@ ci_run(){ local no_cache="${1:-}" export UBUNTU_VERSION=24.04 - CONTAINERD_VERSION=v1.6.36 run "$no_cache" arm64 Dockerfile.origin build-dependencies - UBUNTU_VERSION=20.04 CONTAINERD_VERSION=v1.6.36 run "" arm64 Dockerfile.origin test-integration - + # The actual version may differ CONTAINERD_VERSION=v1.7.25 run "$no_cache" arm64 Dockerfile.origin build-dependencies UBUNTU_VERSION=22.04 CONTAINERD_VERSION=v1.7.25 run "" arm64 Dockerfile.origin test-integration @@ -255,7 +253,7 @@ On a warm cache, it is still over 150MB and 30+ seconds. In and of itself, this is hard to reduce, as we need these... Actions: -- [ ] we could cache the module download location to reduce round-trips on modules that are shared accross +- [ ] we could cache the module download location to reduce round-trips on modules that are shared across different projects - [ ] we are likely installing nerdctl modules six times - (once per architecture during the build phase, then once per ubuntu version and architecture during the tests runs (this is not even accounted for in the audit above)) - it should diff --git a/docs/dev/store.md b/docs/dev/store.md index c0954fb0063..a4bd3a9b20b 100644 --- a/docs/dev/store.md +++ b/docs/dev/store.md @@ -23,7 +23,7 @@ containers can be named the same), etc. However, storing data on the filesystem in a reliable way comes with challenges: - incomplete writes may happen (because of a system restart, or an application crash), leaving important structured files in a broken state -- concurrent writes, or reading while writing would obviously be a problem as well, be it accross goroutines, or between +- concurrent writes, or reading while writing would obviously be a problem as well, be it across goroutines, or between concurrent executions of the nerdctl binary, or embedded in a third-party application that does concurrently access resources The `pkg/store` package does provide a "storage" abstraction that takes care of these issues, generally providing diff --git a/docs/dir.md b/docs/dir.md index 4843eadb6bc..43da6dff528 100644 --- a/docs/dir.md +++ b/docs/dir.md @@ -35,6 +35,7 @@ Files: - `-json.log`: used by `nerdctl logs` - `oci-hook.*.log`: logs of the OCI hook - `lifecycle.json`: used to store stateful information about the container that can only be retrieved through OCI hooks +- `network-config.json`: used to store container-specific network configuration, such as port mappings. ### `//names/` e.g. `/var/lib/nerdctl/1935db59/names/default` @@ -65,7 +66,7 @@ Data volume Can be overridden with `nerdctl --cni-netconfpath=` flag and environment variable `$NETCONFPATH`. -At the top-level of , network (files) are shared accross all namespaces. +At the top-level of , network (files) are shared across all namespaces. Sub-folders inside are only available to the namespace bearing the same name, and its networks definitions are private. diff --git a/docs/gpu.md b/docs/gpu.md index 009170c1a37..cc21247a238 100644 --- a/docs/gpu.md +++ b/docs/gpu.md @@ -3,14 +3,18 @@ | :zap: Requirement | nerdctl >= 0.9 | |-------------------|----------------| +> [!NOTE] +> The description in this section applies to nerdctl v2.3 or later. +> Users of prior releases of nerdctl should refer to + nerdctl provides docker-compatible NVIDIA GPU support. ## Prerequisites - NVIDIA Drivers - Same requirement as when you use GPUs on Docker. For details, please refer to [the doc by NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#pre-requisites). -- `nvidia-container-cli` - - containerd relies on this CLI for setting up GPUs inside container. You can install this via [`libnvidia-container` package](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/arch-overview.html#libnvidia-container). +- The NVIDIA Container Toolkit + - containerd relies on the NVIDIA Container Toolkit to make GPUs usable inside a container. You can install the NVIDIA Container Toolkit by following the [official installation instructions](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). ## Options for `nerdctl run --gpus` @@ -27,23 +31,24 @@ You can also pass detailed configuration to `--gpus` option as a list of key-val - `count`: number of GPUs to use. `all` exposes all available GPUs. - `device`: IDs of GPUs to use. UUID or numbers of GPUs can be specified. -- `capabilities`: [Driver capabilities](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/user-guide.html#driver-capabilities). If unset, use default driver `utility`, `compute`. The following example exposes a specific GPU to the container. ``` -nerdctl run -it --rm --gpus '"capabilities=utility,compute",device=GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a' nvidia/cuda:12.3.1-base-ubuntu20.04 nvidia-smi +nerdctl run -it --rm --gpus 'device=GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a' nvidia/cuda:12.3.1-base-ubuntu20.04 nvidia-smi ``` +Note that although `capabilities` options may be provided, these are ignored when processing the GPU request since nerdctl v2.3. + ## Fields for `nerdctl compose` `nerdctl compose` also supports GPUs following [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/deploy.md#devices). -You can use GPUs on compose when you specify some of the following `capabilities` in `services.demo.deploy.resources.reservations.devices`. +You can use GPUs on compose when you specify the `driver` as `nvidia` or one or +more of the following `capabilities` in `services.demo.deploy.resources.reservations.devices`. - `gpu` - `nvidia` -- all allowed capabilities for `nerdctl run --gpus` Available fields are the same as `nerdctl run --gpus`. @@ -59,12 +64,37 @@ services: resources: reservations: devices: - - capabilities: ["utility"] + - driver: nvidia count: all ``` ## Trouble Shooting +### `nerdctl run --gpus` fails due to an unresolvable CDI device + +If the required CDI specifications for NVIDIA devices are not available on the +system, the `nerdctl run` command will fail with an error similar to: `CDI device injection failed: unresolvable CDI devices nvidia.com/gpu=all` (the +exact error message will depend on the device(s) requested). + +This should be the same error message that is reported when the `--device` flag +is used to request a CDI device: +``` +nerdctl run --device=nvidia.com/gpu=all +``` + +Ensure that the NVIDIA Container Toolkit (>= v1.18.0 is recommended) is installed and the requested CDI devices are present in the ouptut of `nvidia-ctk cdi list`: + +``` +$ nvidia-ctk cdi list +INFO[0000] Found 3 CDI devices +nvidia.com/gpu=0 +nvidia.com/gpu=GPU-3eb87630-93d5-b2b6-b8ff-9b359caf4ee2 +nvidia.com/gpu=all +``` + +See the NVIDIA Container Toolkit [CDI documentation](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/cdi-support.html) for more information. + + ### `nerdctl run --gpus` fails when using the Nvidia gpu-operator If the Nvidia driver is installed by the [gpu-operator](https://github.com/NVIDIA/gpu-operator).The `nerdctl run` will fail with the error message `(FATA[0000] exec: "nvidia-container-cli": executable file not found in $PATH)`. diff --git a/docs/healthchecks.md b/docs/healthchecks.md new file mode 100644 index 00000000000..628a8710a29 --- /dev/null +++ b/docs/healthchecks.md @@ -0,0 +1,96 @@ +# Health Check Support in nerdctl + +`nerdctl` supports Docker-compatible health checks for containers, allowing users to monitor container health via a user-defined command. + +## Configuration Options +| :zap: Requirement | nerdctl >= 2.1.5 | +|-------------------|----------------| + +Health checks can be configured in multiple ways: + +1. At container creation time using `nerdctl run` or `nerdctl create` with these flags: + - `--health-cmd`: Command to run to check health + - `--health-interval`: Time between running the check (default: 30s) + - `--health-timeout`: Maximum time to allow one check to run (default: 30s) + - `--health-retries`: Consecutive failures needed to report unhealthy (default: 3) + - `--health-start-period`: Start period for the container to initialize before starting health-retries countdown + - `--no-healthcheck`: Disable any container-specified HEALTHCHECK + +2. At image build time using HEALTHCHECK in a Dockerfile + +**Note:** The `--health-start-interval` option is currently not supported by nerdctl. + +## Configuration Priority + +When a container is created, nerdctl determines the health check configuration based on this priority: + +1. CLI flags take highest precedence (e.g., `--health-cmd`, etc.) +2. If no CLI flags are set, nerdctl will use any health check defined in the image +3. If neither is present, no health check will be configured + +### Disabling Health Checks + +You can disable health checks using the following flag during container create/run: + +```bash +--no-healthcheck +``` + +### Running Health Checks Manually + +nerdctl provides a container healthcheck command that can be manually triggered by the user. This command runs the +configured health check inside the container and reports the result. It serves as the entry point for executing +health checks, especially in scenarios where external scheduling is used. + +Example: +``` +nerdctl container healthcheck +``` + +## Automatic Health Checks with systemd + +On Linux systems with systemd, nerdctl automatically creates and manages systemd timer units to execute health checks at the configured intervals. This provides reliable scheduling and execution of health checks without requiring a persistent daemon. + +### Requirements for Automatic Health Checks + +- systemd must be available on the system +- Container must not be running in rootless mode +- Configuration property `disable_hc_systemd` must not be set to `true` in nerdctl.toml + +### How It Works + +1. When a container with health checks is created, nerdctl: + - Creates a systemd timer unit for the container + - Configures the timer according to the health check interval + - Starts monitoring the container's health status + +2. The health check status can be one of: + - `starting`: During container initialization + - `healthy`: When health checks are passing + - `unhealthy`: After specified number of consecutive failures +## Examples + +1. Basic health check that verifies a web server: +```bash +nerdctl run -d --name web \ + --health-cmd="curl -f http://localhost/ || exit 1" \ + --health-interval=5s \ + --health-retries=3 \ + nginx +``` + +2. Health check with initialization period: +```bash +nerdctl run -d --name app \ + --health-cmd="./health-check.sh" \ + --health-interval=30s \ + --health-timeout=10s \ + --health-retries=3 \ + --health-start-period=60s \ + myapp +``` + +3. Disable health checks: +```bash +nerdctl run --no-healthcheck myapp +``` diff --git a/docs/multi-platform.md b/docs/multi-platform.md index 5c2e05b9239..e70b5f18c7f 100644 --- a/docs/multi-platform.md +++ b/docs/multi-platform.md @@ -16,6 +16,7 @@ $ sudo nerdctl run --privileged --rm tonistiigi/binfmt:master --install all $ ls -1 /proc/sys/fs/binfmt_misc/qemu* /proc/sys/fs/binfmt_misc/qemu-aarch64 /proc/sys/fs/binfmt_misc/qemu-arm +/proc/sys/fs/binfmt_misc/qemu-loongarch64 /proc/sys/fs/binfmt_misc/qemu-mips64 /proc/sys/fs/binfmt_misc/qemu-mips64el /proc/sys/fs/binfmt_misc/qemu-ppc64le diff --git a/docs/nydus.md b/docs/nydus.md index 1019827a548..c8f912d01cf 100644 --- a/docs/nydus.md +++ b/docs/nydus.md @@ -15,6 +15,11 @@ Nydus snapshotter is a remote snapshotter plugin of containerd for [Nydus](https [proxy_plugins.nydus] type = "snapshot" address = "/run/containerd-nydus-grpc/containerd-nydus-grpc.sock" + +# Optional: Configure nydus for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "nydus" ``` - Launch `containerd` and `containerd-nydus-grpc` diff --git a/docs/overlaybd.md b/docs/overlaybd.md index caa4673403e..e6e6284c42b 100644 --- a/docs/overlaybd.md +++ b/docs/overlaybd.md @@ -17,6 +17,11 @@ See https://github.com/containerd/accelerated-container-image to learn further i [proxy_plugins.overlaybd] type = "snapshot" address = "/run/overlaybd-snapshotter/overlaybd.sock" + +# Optional: Configure overlaybd for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlaybd" ``` - Launch `containerd` and `overlaybd-snapshotter` diff --git a/docs/rootless.md b/docs/rootless.md index 1000bd50865..4b3f593f760 100644 --- a/docs/rootless.md +++ b/docs/rootless.md @@ -73,6 +73,11 @@ Then, add the following config to `~/.config/containerd/config.toml`, and run `s type = "snapshot" # NOTE: replace "1000" with your actual UID address = "/run/user/1000/containerd-fuse-overlayfs.sock" + +# Optional: Configure fuse-overlayfs for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "fuse-overlayfs" ``` The snapshotter can be specified as `$CONTAINERD_SNAPSHOTTER`. @@ -98,6 +103,11 @@ Then, add the following config to `~/.config/containerd/config.toml` and run `sy type = "snapshot" # NOTE: replace "1000" with your actual UID address = "/run/user/1000/containerd-stargz-grpc/containerd-stargz-grpc.sock" + +# Optional: Configure stargz for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" ``` The snapshotter can be specified as `$CONTAINERD_SNAPSHOTTER`. diff --git a/docs/soci.md b/docs/soci.md index 67fbe92f584..e79b7f472d9 100644 --- a/docs/soci.md +++ b/docs/soci.md @@ -4,6 +4,22 @@ SOCI Snapshotter is a containerd snapshotter plugin. It enables standard OCI ima See https://github.com/awslabs/soci-snapshotter to learn further information. +## SOCI Index Manifest Versions + +SOCI supports two index manifest versions: + +- **v1**: Original format using OCI Referrers API (disabled by default in SOCI v0.10.0+) +- **v2**: New format that packages SOCI index with the image (default in SOCI v0.10.0+) + +To enable v1 indices in SOCI v0.10.0+, add to `/etc/soci-snapshotter-grpc/config.toml`: +```toml +[pull_modes] + [pull_modes.soci_v1] + enable = true +``` + +For detailed information about the differences between v1 and v2, see the [SOCI Index Manifest v2 documentation](https://github.com/awslabs/soci-snapshotter/blob/main/docs/soci-index-manifest-v2.md). + ## Prerequisites - Install containerd remote snapshotter plugin (`soci-snapshotter-grpc`) from https://github.com/awslabs/soci-snapshotter/blob/main/docs/getting-started.md @@ -14,6 +30,11 @@ See https://github.com/awslabs/soci-snapshotter to learn further information. [proxy_plugins.soci] type = "snapshot" address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock" + +# Optional: Configure soci for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "soci" ``` - Launch `containerd` and `soci-snapshotter-grpc` @@ -45,3 +66,21 @@ For images that already have SOCI indices, see https://gallery.ecr.aws/soci-work nerdctl push --snapshotter=soci --soci-span-size=2097152 --soci-min-layer-size=20971520 public.ecr.aws/my-registry/my-repo:latest ``` --soci-span-size and --soci-min-layer-size are two properties to customize the SOCI index. See [Command Reference](https://github.com/containerd/nerdctl/blob/377b2077bb616194a8ef1e19ccde32aa1ffd6c84/docs/command-reference.md?plain=1#L773) for further details. + +> **Note**: With SOCI v0.10.0+, When using `nerdctl push --snapshotter=soci`, it creates and pushes v1 indices. When pushing a converted image (created with `nerdctl image convert --soci`), it will push v2 indices. + +## Enable SOCI for `nerdctl image convert` + +| :zap: Requirement | nerdctl >= 2.1.3 | +| ----------------- | ---------------- | + +| :zap: Requirement | soci-snapshotter >= 0.10.0 | +| ----------------- | ---------------- | + +- Convert an image to generate SOCI Index artifacts v2. Running the `nerdctl image convert` with the `--soci` flag and a `srcImg` and `dstImg`, `nerdctl` will create the SOCI v2 indices and the new image will be present in the `dstImg` address. +```console +nerdctl image convert --soci --soci-span-size=2097152 --soci-min-layer-size=20971520 public.ecr.aws/my-registry/my-repo:latest public.ecr.aws/my-registry/my-repo:soci +``` +--soci-span-size and --soci-min-layer-size are two properties to customize the SOCI index. See [Command Reference](https://github.com/containerd/nerdctl/blob/377b2077bb616194a8ef1e19ccde32aa1ffd6c84/docs/command-reference.md?plain=1#L773) for further details. + +The `image convert` command with `--soci` flag creates SOCI-enabled images using SOCI Index Manifest v2, which combines the SOCI index and the original image into a single artifact. diff --git a/docs/stargz.md b/docs/stargz.md index 5a54fe17906..16e5c365c22 100644 --- a/docs/stargz.md +++ b/docs/stargz.md @@ -22,6 +22,11 @@ See https://github.com/containerd/stargz-snapshotter to learn further informatio [proxy_plugins.stargz] type = "snapshot" address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock" + +# Optional: Configure stargz for image unpacking (allows automatic snapshotter selection) +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" ``` - Launch `containerd` and `containerd-stargz-grpc` @@ -92,7 +97,20 @@ Stargz Snapshotter is not needed for building stargz images. ## Tips for image conversion -### Tips 1: Creating smaller eStargz images +### Tips 1: Using gzip helper to speed up image conversion + +When converting a traditional overlayfs image encoded as tar.gz to an estargz format image, nerdctl supports specifying an additional command‑line decompression tool to speed up the conversion process. You can set `--estargz-gzip-helper` to choose different CLI gzip tools. Even using the gzip command corresponding to the Go gzip library can achieve approximately 32% speed improvement. For more details, see: [Using decompression commands to improve the layer decompression speed of gzip-formatted images](https://github.com/containerd/stargz-snapshotter/pull/2117). Currently, `--estargz-gzip-helper` supports `pigz`, `igzip`, and `gzip`. The recommended order is `pigz` > `igzip` > `gzip`. + +```console +# nerdctl image convert --oci --estargz --estargz-gzip-helper pigz ghcr.io/stargz-containers/ubuntu:22.04 ghcr.io/stargz-containers/ubuntu:22.04-esgz +sha256:aa6543b9885867b8b485925b6ec69d8e018e8fce40835ea6359cbb573683a014 +# nerdctl image ls +REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE +ghcr.io/stargz-containers/ubuntu 22.04-esgz aa6543b98858 About a minute ago linux/amd64 0B 32.43MB +ghcr.io/stargz-containers/ubuntu 22.04 20fa2d7bb4de 2 minutes ago linux/amd64 87.47MB 30.43MB +``` + +### Tips 2: Creating smaller eStargz images `nerdctl image convert` allows the following flags for optionally creating a smaller eStargz image. The result image requires stargz-snapshotter >= v0.13.0 for lazy pulling. @@ -167,7 +185,7 @@ sha256:7f5cbd8cc787c8d628630756bcc7240e6c96b876c2882e6fc980a8b60cdfa274 sha256:7f5cbd8cc787c8d628630756bcc7240e6c96b876c2882e6fc980a8b60cdfa274 ``` -### Tips 2: Using zstd instead of gzip (a.k.a. zstd:chunked) +### Tips 3: Using zstd instead of gzip (a.k.a. zstd:chunked) You can use zstd compression with lazy pulling support (a.k.a zstd:chunked) instead of gzip. diff --git a/docs/testing/tools.md b/docs/testing/tools.md index 9b4f0d9d1ad..1274c51b4a7 100644 --- a/docs/testing/tools.md +++ b/docs/testing/tools.md @@ -88,17 +88,13 @@ import ( ) func MyComparator(compare string) test.Comparator { - return func(stdout string, info string, t *testing.T) { + return func(stdout string, t tig.T) { t.Helper() - assert.Assert(t, stdout == compare, info) + assert.Assert(t, stdout == compare) } } ``` -Note that you have access to an opaque `info` string. -It contains relevant debugging information in case your comparator is going to fail, -and you should make sure it is displayed. - ### Advanced expectations You may want to have expectations that contain a certain piece of data @@ -142,8 +138,8 @@ func TestMyThing(t *testing.T) { errors.New("foobla"), errdefs.ErrNotFound, }, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, stdout == data.Labels().Get("sometestdata"), info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, stdout == data.Labels().Get("sometestdata")) }, } }, @@ -255,8 +251,8 @@ func TestMyThing(t *testing.T) { errors.New("foobla"), errdefs.ErrNotFound, }, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, stdout == data.Labels().Get("sometestdata"), info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, stdout == data.Labels().Get("sometestdata")) }, } }, @@ -344,8 +340,8 @@ func TestMyThing(t *testing.T) { errors.New("foobla"), errdefs.ErrNotFound, }, - Output: func(stdout string, info string, t *testing.T) { - assert.Assert(t, stdout == data.Labels().Get("sometestdata"), info) + Output: func(stdout string, t tig.T) { + assert.Assert(t, stdout == data.Labels().Get("sometestdata")) }, } }, @@ -398,14 +394,29 @@ nerdtest.Soci // a test requires the soci snapshotter nerdtest.Stargz // a test requires the stargz snapshotter nerdtest.Rootless // a test requires Rootless nerdtest.Rootful // a test requires Rootful +nerdtest.RootlessWithDetachNetNS // a test requires rootless with detached netns (RootlessKit v2) +nerdtest.RootlessWithoutDetachNetNS // a test requires rootless without detached netns (RootlessKit v1) nerdtest.Build // a test requires buildkit nerdtest.CGroup // a test requires cgroup +nerdtest.CgroupsAccessible // a test requires cgroup; passes if rootful, or rootless with cgroup v2 +nerdtest.CGroupV2 // a test requires cgroup v2 nerdtest.NerdctlNeedsFixing // indicates that a test cannot be run on nerdctl yet as a fix is required nerdtest.BrokenTest // indicates that a test needs to be fixed and has been restricted to run only in certain cases nerdtest.OnlyIPv6 // a test is meant to run solely in the ipv6 environment nerdtest.OnlyKubernetes // a test is meant to run solely in the Kubernetes environment nerdtest.IsFlaky // indicates that a test will fail in a flaky way - this may be the test fault, or more likely something racy in nerdctl nerdtest.Private // see below +nerdtest.Registry // a test requires a registry to be deployed +nerdtest.IPFS // a test requires ipfs (binary present) +nerdtest.Gomodjail // a test requires the target binary to be packed with gomodjail +nerdtest.AllowModifyUserns // a test requires allow-modify-userns to be enabled +nerdtest.RemapIDs // a test requires snapshotter to support ID remapping +nerdtest.HyperV // a test requires Hyper-V (Windows) + +nerdtest.Info(func(info dockercompat.Info) error { ... }) // `nerdctl info` should satisfy custom conditions +nerdtest.SociVersion("0.10.0") // SOCI snapshotter version check +nerdtest.ContainerdVersion("2.0.0") // containerd version check +nerdtest.CNIFirewallVersion("1.7.1") // CNI firewall plugin version check ``` ### About `nerdtest.Private` diff --git a/examples/nerdctl-as-a-library/README.md b/examples/nerdctl-as-a-library/README.md new file mode 100644 index 00000000000..8ee5e3695f1 --- /dev/null +++ b/examples/nerdctl-as-a-library/README.md @@ -0,0 +1,3 @@ +# Using nerdctl as a library + +This directory contains examples showing how to implement a cli communicating with containerd, using nerdctl as a library. diff --git a/examples/nerdctl-as-a-library/run-container/main.go b/examples/nerdctl-as-a-library/run-container/main.go new file mode 100644 index 00000000000..4988ec503d4 --- /dev/null +++ b/examples/nerdctl-as-a-library/run-container/main.go @@ -0,0 +1,109 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + nerdctl "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/config" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/logging" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) + +func main() { + // Implement logging + if len(os.Args) == 3 && os.Args[1] == logging.MagicArgv1 { + err := logging.Main(os.Args[2]) + if err != nil { + fmt.Println(err) + return + } + } + + // Get options + globalOpt := types.GlobalCommandOptions(*config.New()) + + // Rootless + _ = rootlessutil.ParentMain(globalOpt.HostGatewayIP) + + // Printout options for debug + f, _ := json.MarshalIndent(globalOpt, "", " ") + fmt.Printf("%s\n", f) + + // Create container options + createOpt := types.ContainerCreateOptions{ + GOptions: globalOpt, + // TODO: this example should implement oci-hook as well instead of relying on nerdctl + NerdctlCmd: "/usr/local/bin/nerdctl", + Name: "my-container", + Label: []string{}, + Cgroupns: "private", + InRun: true, + Rm: false, + Pull: "missing", + LogDriver: "json-file", + StopSignal: "SIGTERM", + Restart: "unless-stopped", + Interactive: true, + } + + // Create client + client, ctx, cancel, err := clientutil.NewClient(context.Background(), globalOpt.Namespace, globalOpt.Address) + if err != nil { + fmt.Println(err) + return + } + defer cancel() + + // Create network manager + networkManager, err := containerutil.NewNetworkingOptionsManager(createOpt.GOptions, types.NetworkOptions{ + NetworkSlice: []string{"bridge"}, + }, client) + + if err != nil { + fmt.Println(err) + return + } + + // Create container + container, _, err := nerdctl.Create(ctx, client, []string{"debian"}, networkManager, createOpt) + if err != nil { + fmt.Println(err) + return + } + + // Start container + err = nerdctl.Start(ctx, client, []string{"my-container"}, types.ContainerStartOptions{ + Attach: true, + Stdout: os.Stdout, + }) + + if err != nil { + fmt.Println(err) + return + } + + cc, _ := json.MarshalIndent(container, "", " ") + fmt.Println(string(cc)) +} diff --git a/extras/rootless/containerd-rootless-setuptool.sh b/extras/rootless/containerd-rootless-setuptool.sh index 27627640d51..c0f754e37b8 100755 --- a/extras/rootless/containerd-rootless-setuptool.sh +++ b/extras/rootless/containerd-rootless-setuptool.sh @@ -404,6 +404,15 @@ cmd_entrypoint_install_fuse_overlayfs() { [proxy_plugins."fuse-overlayfs"] type = "snapshot" address = "${XDG_RUNTIME_DIR}/containerd-fuse-overlayfs.sock" + [proxy_plugins."fuse-overlayfs".exports] + root = "${XDG_DATA_HOME}/containerd-fuse-overlayfs/" + enable_remote_snapshot_annotations = "true" + [[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "fuse-overlayfs" + [[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlayfs" ### END ### EOT INFO "Set \`export CONTAINERD_SNAPSHOTTER=\"fuse-overlayfs\"\` to use the fuse-overlayfs snapshotter." @@ -449,6 +458,15 @@ cmd_entrypoint_install_stargz() { [proxy_plugins."stargz"] type = "snapshot" address = "${XDG_RUNTIME_DIR}/containerd-stargz-grpc/containerd-stargz-grpc.sock" + [proxy_plugins.stargz.exports] + root = "${XDG_DATA_HOME}/containerd-stargz-grpc/" + enable_remote_snapshot_annotations = "true" + [[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "stargz" + [[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux" + snapshotter = "overlayfs" ### END ### EOT INFO "Set \`export CONTAINERD_SNAPSHOTTER=\"stargz\"\` to use the stargz snapshotter." diff --git a/go.mod b/go.mod index 40c88753935..c85aaed76cb 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,48 @@ //gomodjail:confined module github.com/containerd/nerdctl/v2 -go 1.23.0 +go 1.24.3 require ( - github.com/Masterminds/semver/v3 v3.3.1 + github.com/Masterminds/semver/v3 v3.4.0 github.com/Microsoft/go-winio v0.6.2 - github.com/Microsoft/hcsshim v0.13.0 - github.com/compose-spec/compose-go/v2 v2.6.2 //gomodjail:unconfined + github.com/Microsoft/hcsshim v0.14.0-rc.1 + github.com/compose-spec/compose-go/v2 v2.10.0 //gomodjail:unconfined github.com/containerd/accelerated-container-image v1.3.0 - github.com/containerd/cgroups/v3 v3.0.5 //gomodjail:unconfined - github.com/containerd/console v1.0.4 //gomodjail:unconfined - github.com/containerd/containerd/api v1.9.0 - github.com/containerd/containerd/v2 v2.1.0 //gomodjail:unconfined + github.com/containerd/cgroups/v3 v3.1.2 //gomodjail:unconfined + github.com/containerd/console v1.0.5 //gomodjail:unconfined + github.com/containerd/containerd/api v1.10.0 + github.com/containerd/containerd/v2 v2.2.1 //gomodjail:unconfined github.com/containerd/continuity v0.4.5 //gomodjail:unconfined github.com/containerd/errdefs v1.0.0 github.com/containerd/fifo v1.1.0 //gomodjail:unconfined - github.com/containerd/go-cni v1.1.12 //gomodjail:unconfined - github.com/containerd/imgcrypt/v2 v2.0.1 //gomodjail:unconfined + github.com/containerd/go-cni v1.1.13 //gomodjail:unconfined + github.com/containerd/imgcrypt/v2 v2.0.2 //gomodjail:unconfined github.com/containerd/log v0.1.0 github.com/containerd/nerdctl/mod/tigron v0.0.0 - github.com/containerd/nydus-snapshotter v0.15.1 //gomodjail:unconfined - github.com/containerd/platforms v1.0.0-rc.1 //gomodjail:unconfined - github.com/containerd/stargz-snapshotter v0.16.3 //gomodjail:unconfined - github.com/containerd/stargz-snapshotter/estargz v0.16.3 //gomodjail:unconfined - github.com/containerd/stargz-snapshotter/ipfs v0.16.3 //gomodjail:unconfined + github.com/containerd/nydus-snapshotter v0.15.10 //gomodjail:unconfined + github.com/containerd/platforms v1.0.0-rc.2 //gomodjail:unconfined + github.com/containerd/stargz-snapshotter v0.18.1 //gomodjail:unconfined + github.com/containerd/stargz-snapshotter/estargz v0.18.1 //gomodjail:unconfined + github.com/containerd/stargz-snapshotter/ipfs v0.18.1 //gomodjail:unconfined github.com/containerd/typeurl/v2 v2.2.3 github.com/containernetworking/cni v1.3.0 //gomodjail:unconfined - github.com/containernetworking/plugins v1.7.1 //gomodjail:unconfined - github.com/coreos/go-iptables v0.8.0 - github.com/coreos/go-systemd/v22 v22.5.0 - github.com/cyphar/filepath-securejoin v0.4.1 //gomodjail:unconfined + github.com/containernetworking/plugins v1.9.0 //gomodjail:unconfined + github.com/coreos/go-iptables v0.8.0 //gomodjail:unconfined + github.com/coreos/go-systemd/v22 v22.6.0 + github.com/cyphar/filepath-securejoin v0.6.1 //gomodjail:unconfined github.com/distribution/reference v0.6.0 - github.com/docker/cli v28.1.1+incompatible //gomodjail:unconfined - github.com/docker/docker v28.1.1+incompatible //gomodjail:unconfined - github.com/docker/go-connections v0.5.0 + github.com/docker/cli v29.1.4+incompatible //gomodjail:unconfined + github.com/docker/docker v28.5.2+incompatible //gomodjail:unconfined + github.com/docker/go-connections v0.6.0 github.com/docker/go-units v0.5.0 github.com/fahedouch/go-logrotate v0.3.0 //gomodjail:unconfined github.com/fatih/color v1.18.0 //gomodjail:unconfined - github.com/fluent/fluent-logger-golang v1.9.0 + github.com/fluent/fluent-logger-golang v1.10.1 github.com/fsnotify/fsnotify v1.9.0 //gomodjail:unconfined - github.com/go-viper/mapstructure/v2 v2.2.1 - github.com/ipfs/go-cid v0.5.0 - github.com/klauspost/compress v1.18.0 + github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/ipfs/go-cid v0.6.0 + github.com/klauspost/compress v1.18.2 github.com/mattn/go-isatty v0.0.20 //gomodjail:unconfined github.com/moby/sys/mount v0.3.4 github.com/moby/sys/signal v0.7.1 @@ -52,30 +52,29 @@ require ( github.com/muesli/cancelreader v0.2.2 //gomodjail:unconfined github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/opencontainers/runtime-spec v1.2.1 + github.com/opencontainers/runtime-spec v1.3.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/rootless-containers/bypass4netns v0.4.2 //gomodjail:unconfined - github.com/rootless-containers/rootlesskit/v2 v2.3.5 //gomodjail:unconfined - github.com/spf13/cobra v1.9.1 //gomodjail:unconfined - github.com/spf13/pflag v1.0.6 //gomodjail:unconfined - github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8 //gomodjail:unconfined + github.com/rootless-containers/rootlesskit/v2 v2.3.6 //gomodjail:unconfined + github.com/spf13/cobra v1.10.2 //gomodjail:unconfined + github.com/spf13/pflag v1.0.10 //gomodjail:unconfined + github.com/vishvananda/netlink v1.3.1 //gomodjail:unconfined github.com/vishvananda/netns v0.0.5 //gomodjail:unconfined github.com/yuchanns/srslog v1.1.0 - go.uber.org/mock v0.5.2 - golang.org/x/crypto v0.38.0 - golang.org/x/net v0.40.0 - golang.org/x/sync v0.14.0 //gomodjail:unconfined - golang.org/x/sys v0.33.0 //gomodjail:unconfined - golang.org/x/term v0.32.0 //gomodjail:unconfined - golang.org/x/text v0.25.0 - gopkg.in/yaml.v3 v3.0.1 + go.uber.org/mock v0.6.0 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 //gomodjail:unconfined + golang.org/x/sys v0.40.0 //gomodjail:unconfined + golang.org/x/term v0.39.0 //gomodjail:unconfined + golang.org/x/text v0.33.0 gotest.tools/v3 v3.5.2 - tags.cncf.io/container-device-interface v1.0.1 //gomodjail:unconfined + tags.cncf.io/container-device-interface v1.1.0 //gomodjail:unconfined ) require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/cilium/ebpf v0.16.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/go-runc v1.1.0 // indirect @@ -86,12 +85,12 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -109,45 +108,51 @@ require ( github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multiaddr v0.13.0 // indirect + github.com/multiformats/go-multiaddr v0.16.1 // indirect github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect - github.com/multiformats/go-varint v0.0.7 // indirect - github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 // indirect - github.com/opencontainers/selinux v1.12.0 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect + github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 // indirect + github.com/opencontainers/selinux v1.13.1 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect - github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect //gomodjail:unconfined github.com/sirupsen/logrus v1.9.3 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect - //gomodjail:unconfined - github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect - github.com/tinylib/msgp v1.2.0 // indirect - github.com/vbatts/tar-split v0.11.6 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect - golang.org/x/mod v0.24.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + golang.org/x/mod v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect //gomodjail:unconfined - google.golang.org/grpc v1.72.0 // indirect + google.golang.org/grpc v1.76.0 // indirect //gomodjail:unconfined - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.3.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect - tags.cncf.io/container-device-interface/specs-go v1.0.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect + tags.cncf.io/container-device-interface/specs-go v1.1.0 // indirect +) + +require ( + cyphar.com/go-pathrs v0.2.1 // indirect + github.com/moby/moby/api v1.52.0 // indirect + github.com/moby/moby/client v0.1.0 // indirect + github.com/moby/sys/capability v0.4.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect ) replace github.com/containerd/nerdctl/mod/tigron v0.0.0 => ./mod/tigron diff --git a/go.sum b/go.sum index 5455c1222c1..f46ead09765 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -6,33 +8,31 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA= -github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= +github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= +github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/compose-spec/compose-go/v2 v2.6.2 h1:31uZNNLeRrKjtUCc56CzPpPykW1Tm6SxLn4gx9Jjzqw= -github.com/compose-spec/compose-go/v2 v2.6.2/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA= +github.com/compose-spec/compose-go/v2 v2.10.0 h1:K2C5LQ3KXvkYpy5N/SG6kIYB90iiAirA9btoTh/gB0Y= +github.com/compose-spec/compose-go/v2 v2.10.0/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg= github.com/containerd/accelerated-container-image v1.3.0 h1:sFbTgSuMboeKHa9f7MY11hWF1XxVWjFoiTsXYtOtvdU= github.com/containerd/accelerated-container-image v1.3.0/go.mod h1:EvKVWor6ZQNUyYp0MZm5hw4k21ropuz7EegM+m/Jb/Q= -github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo= -github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= -github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= -github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= -github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= -github.com/containerd/containerd/v2 v2.1.0 h1:lS6iJ/CwZrxYxKd6zWBz5LR7xOlMVQC78z68YtizUAM= -github.com/containerd/containerd/v2 v2.1.0/go.mod h1:t2VqM0zSiEdi33qgtsMwUKrYyVg4oq2FPe+cs3LBt7w= +github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= +github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= +github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= +github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= +github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= +github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk= +github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -41,45 +41,45 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= -github.com/containerd/go-cni v1.1.12 h1:wm/5VD/i255hjM4uIZjBRiEQ7y98W9ACy/mHeLi4+94= -github.com/containerd/go-cni v1.1.12/go.mod h1:+jaqRBdtW5faJxj2Qwg1Of7GsV66xcvnCx4mSJtUlxU= +github.com/containerd/go-cni v1.1.13 h1:eFSGOKlhoYNxpJ51KRIMHZNlg5UgocXEIEBGkY7Hnis= +github.com/containerd/go-cni v1.1.13/go.mod h1:nTieub0XDRmvCZ9VI/SBG6PyqT95N4FIhxsauF1vSBI= github.com/containerd/go-runc v1.1.0 h1:OX4f+/i2y5sUT7LhmcJH7GYrjjhHa1QI4e8yO0gGleA= github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U= -github.com/containerd/imgcrypt/v2 v2.0.1 h1:gQcmeCKA97fAl0wlpq0itSY/PagFBsn4/mlKUy6kOio= -github.com/containerd/imgcrypt/v2 v2.0.1/go.mod h1:/qIJL8nxzdzMA2n5iYyyuIY36KfoVQWmgTWdfVtyebM= +github.com/containerd/imgcrypt/v2 v2.0.2 h1:WOEaE33CaSxzuRF8YLfAjHWuu1Xh27aPPQtqtALqfuM= +github.com/containerd/imgcrypt/v2 v2.0.2/go.mod h1:8r4JW1b83jkDhaioOUZ7idxIYp+Wn1k4E4KXwy2oSNI= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/nydus-snapshotter v0.15.1 h1:huPj2d8J1BEx6mjm6h72BCo1kY5lTrfatnnujzpu6BA= -github.com/containerd/nydus-snapshotter v0.15.1/go.mod h1:FfwH2KBkNYoisK/e+KsmNr7xTU53DmnavQHMFOcXwfM= -github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E= -github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/containerd/nydus-snapshotter v0.15.10 h1:hphjuKOqSHLGznNJiAvmsOWkdu4qFXjf4DzGrWSuIsM= +github.com/containerd/nydus-snapshotter v0.15.10/go.mod h1:EWRd/QJ0b6UKHAqYgiV5gHlqLC2qq5cQiSlXEdVovrA= +github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= +github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y= github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= -github.com/containerd/stargz-snapshotter v0.16.3 h1:zbQMm8dRuPHEOD4OqAYGajJJUwCeUzt4j7w9Iaw58u4= -github.com/containerd/stargz-snapshotter v0.16.3/go.mod h1:XPOl2oa9zjWidTM2IX191smolwWc3/zkKtp02TzTFb0= -github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= -github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/containerd/stargz-snapshotter/ipfs v0.16.3 h1:d6IBSzYo0vlFcujwTqJRwpI3cZgX3E2I6Ev7LtMaZ4M= -github.com/containerd/stargz-snapshotter/ipfs v0.16.3/go.mod h1:d4EuGnC3RteInKAdddUbDOL88uw3vZySSLZ44pbriGM= +github.com/containerd/stargz-snapshotter v0.18.1 h1:eIkwsafohSWas5YmhxoumrI7elmb2EZJcW8eu7goyOY= +github.com/containerd/stargz-snapshotter v0.18.1/go.mod h1:HPC+XHGIxkjWfAONMvXepQyOs8iGApP2e5A3fOv2TCU= +github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= +github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= +github.com/containerd/stargz-snapshotter/ipfs v0.18.1 h1:v0kozDNJCW1iVy/1MgD5uZImW87CvkHLzb9L9JwfOco= +github.com/containerd/stargz-snapshotter/ipfs v0.18.1/go.mod h1:qgy0jrKhqtLxn6J5rb9BXZ1Xj1Xeimd0vOi25Zyhpds= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= -github.com/containernetworking/plugins v1.7.1 h1:CNAR0jviDj6FS5Vg85NTgKWLDzZPfi/lj+VJfhMDTIs= -github.com/containernetworking/plugins v1.7.1/go.mod h1:xuMdjuio+a1oVQsHKjr/mgzuZ24leAsqUYRnzGoXHy0= +github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= +github.com/containernetworking/plugins v1.9.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c= github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM= github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ= github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -88,14 +88,16 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= -github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= -github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v29.1.4+incompatible h1:AI8fwZhqsAsrqZnVv9h6lbexeW/LzNTasf6A4vcNN8M= +github.com/docker/cli v29.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -108,32 +110,31 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fluent/fluent-logger-golang v1.9.0 h1:zUdY44CHX2oIUc7VTNZc+4m+ORuO/mldQDA7czhWXEg= -github.com/fluent/fluent-logger-golang v1.9.0/go.mod h1:2/HCT/jTy78yGyeNGQLGQsjF3zzzAuy6Xlk6FCMV5eU= +github.com/fluent/fluent-logger-golang v1.10.1 h1:wu54iN1O2afll5oQrtTjhgZRwWcfOeFFzwRsEkABfFQ= +github.com/fluent/fluent-logger-golang v1.10.1/go.mod h1:qOuXG4ZMrXaSTk12ua+uAb21xfNYOzn0roAtp7mfGAE= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= +github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -152,33 +153,30 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= -github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= +github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= +github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -202,11 +200,16 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mndrix/tap-go v0.0.0-20171203230836-629fa407e90b/go.mod h1:pzzDgJWZ34fGzaAZGFW22KVZDfyrYW+QABMrWnJBnSs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8= +github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= +github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= github.com/moby/sys/mount v0.3.4 h1:yn5jq4STPztkkzSKpZkLcmjue+bZJ0u2AuQY1iNI1Ww= github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= @@ -225,98 +228,90 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= -github.com/multiformats/go-multiaddr v0.13.0 h1:BCBzs61E3AGHcYYTv8dqRH43ZfyrqM8RXVPT8t13tLQ= -github.com/multiformats/go-multiaddr v0.13.0/go.mod h1:sBXrNzucqkFJhvKOiwwLyqamGa/P5EIXNPLovyhQCII= +github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= +github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= -github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= -github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= +github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= +github.com/onsi/gomega v1.38.1 h1:FaLA8GlcpXDwsb7m0h2A9ew2aTk3vnZMlzFgg5tz/pk= +github.com/onsi/gomega v1.38.1/go.mod h1:LfcV8wZLvwcYRwPiJysphKAEsmcFnLMK/9c+PjvlX8g= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0= -github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI= -github.com/opencontainers/selinux v1.9.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= -github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8= -github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= +github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= +github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 h1:jYi87L8j62qkXzaYHAQAhEapgukhenIMZRBKTNRLHJ4= -github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rootless-containers/bypass4netns v0.4.2 h1:JUZcpX7VLRfDkLxBPC6fyNalJGv9MjnjECOilZIvKRc= github.com/rootless-containers/bypass4netns v0.4.2/go.mod h1:iOY28IeFVqFHnK0qkBCQ3eKzKQgSW5DtlXFQJyJMAQk= -github.com/rootless-containers/rootlesskit/v2 v2.3.5 h1:WGY05oHE7xQpSkCGfYP9lMY5z19tCxA8PhWlvP1cKx8= -github.com/rootless-containers/rootlesskit/v2 v2.3.5/go.mod h1:83EIYLeMX8UeNgLHkR1PefoSV76aKEC+OyI3vzrEfvw= +github.com/rootless-containers/rootlesskit/v2 v2.3.6 h1:m/26nAx0DbHZYaM46+uoQjfpu9G77QLzWj2jz25chO8= +github.com/rootless-containers/rootlesskit/v2 v2.3.6/go.mod h1:pv+RESmjRmeUIOsEWOT1f8560CrdaQrDW0YsF4K5kAY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= -github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= -github.com/tinylib/msgp v1.2.0 h1:0uKB/662twsVBpYUPbokj4sTSKhWFKB7LopO2kWK8lY= -github.com/tinylib/msgp v1.2.0/go.mod h1:2vIGs3lcUo8izAATNobrCHevYZC/LMsJtw4JPiYPHro= -github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= -github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= -github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8 h1:Y4egeTrP7sccowz2GWTJVtHlwkZippgBTpUmMteFUWQ= -github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -336,22 +331,28 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -360,8 +361,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= @@ -375,8 +376,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -393,8 +394,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -407,13 +408,11 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -433,8 +432,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -444,8 +443,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -455,8 +454,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -469,26 +468,28 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -498,8 +499,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -514,9 +515,11 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= -tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc= -tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0= -tags.cncf.io/container-device-interface/specs-go v1.0.0 h1:8gLw29hH1ZQP9K1YtAzpvkHCjjyIxHZYzBAvlQ+0vD8= -tags.cncf.io/container-device-interface/specs-go v1.0.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY= +tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q= +tags.cncf.io/container-device-interface/specs-go v1.1.0 h1:QRZVeAceQM+zTZe12eyfuJuuzp524EKYwhmvLd+h+yQ= +tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ= diff --git a/hack/build-integration-canary.sh b/hack/build-integration-canary.sh index ae205c90bed..725628b962a 100755 --- a/hack/build-integration-canary.sh +++ b/hack/build-integration-canary.sh @@ -28,7 +28,9 @@ readonly root # "Blacklisting" here means that any dependency which name is blacklisted will be left untouched, at the version # currently pinned in the Dockerfile. # This is convenient so that currently broken alpha/beta/RC can be held back temporarily to keep the build green -blacklist=() +# TODO: Blacklisting gotestsum until a new version compatible with golang v1.25rc1 is released +# Issue: https://github.com/google/go-licenses/issues/312 +blacklist=(gotestsum) # List all the repositories we depend on to build and run integration tests dependencies=( diff --git a/hack/generate-release-note.sh b/hack/generate-release-note.sh index 68f876e17f4..54124b79500 100755 --- a/hack/generate-release-note.sh +++ b/hack/generate-release-note.sh @@ -25,7 +25,8 @@ cat <<-EOX (To be documented) ## Compatible containerd versions -This release of nerdctl is expected to be used with containerd v1.6, v1.7, v2.0, or v2.1. +This release of nerdctl is expected to be used with containerd v1.7, v2.0, v2.1, or v2.2. +Some features may not work with other releases of containerd. ## About the binaries - Minimal (\`${minimal_amd64tgz_basename}\`): nerdctl only diff --git a/hack/provisioning/gpg/docker b/hack/provisioning/gpg/docker new file mode 100644 index 00000000000..ee7872e5d03 --- /dev/null +++ b/hack/provisioning/gpg/docker @@ -0,0 +1,62 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFit2ioBEADhWpZ8/wvZ6hUTiXOwQHXMAlaFHcPH9hAtr4F1y2+OYdbtMuth +lqqwp028AqyY+PRfVMtSYMbjuQuu5byyKR01BbqYhuS3jtqQmljZ/bJvXqnmiVXh +38UuLa+z077PxyxQhu5BbqntTPQMfiyqEiU+BKbq2WmANUKQf+1AmZY/IruOXbnq +L4C1+gJ8vfmXQt99npCaxEjaNRVYfOS8QcixNzHUYnb6emjlANyEVlZzeqo7XKl7 +UrwV5inawTSzWNvtjEjj4nJL8NsLwscpLPQUhTQ+7BbQXAwAmeHCUTQIvvWXqw0N +cmhh4HgeQscQHYgOJjjDVfoY5MucvglbIgCqfzAHW9jxmRL4qbMZj+b1XoePEtht +ku4bIQN1X5P07fNWzlgaRL5Z4POXDDZTlIQ/El58j9kp4bnWRCJW0lya+f8ocodo +vZZ+Doi+fy4D5ZGrL4XEcIQP/Lv5uFyf+kQtl/94VFYVJOleAv8W92KdgDkhTcTD +G7c0tIkVEKNUq48b3aQ64NOZQW7fVjfoKwEZdOqPE72Pa45jrZzvUFxSpdiNk2tZ +XYukHjlxxEgBdC/J3cMMNRE1F4NCA3ApfV1Y7/hTeOnmDuDYwr9/obA8t016Yljj +q5rdkywPf4JF8mXUW5eCN1vAFHxeg9ZWemhBtQmGxXnw9M+z6hWwc6ahmwARAQAB +tCtEb2NrZXIgUmVsZWFzZSAoQ0UgZGViKSA8ZG9ja2VyQGRvY2tlci5jb20+iQI3 +BBMBCgAhBQJYrefAAhsvBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEI2BgDwO +v82IsskP/iQZo68flDQmNvn8X5XTd6RRaUH33kXYXquT6NkHJciS7E2gTJmqvMqd +tI4mNYHCSEYxI5qrcYV5YqX9P6+Ko+vozo4nseUQLPH/ATQ4qL0Zok+1jkag3Lgk +jonyUf9bwtWxFp05HC3GMHPhhcUSexCxQLQvnFWXD2sWLKivHp2fT8QbRGeZ+d3m +6fqcd5Fu7pxsqm0EUDK5NL+nPIgYhN+auTrhgzhK1CShfGccM/wfRlei9Utz6p9P +XRKIlWnXtT4qNGZNTN0tR+NLG/6Bqd8OYBaFAUcue/w1VW6JQ2VGYZHnZu9S8LMc +FYBa5Ig9PxwGQOgq6RDKDbV+PqTQT5EFMeR1mrjckk4DQJjbxeMZbiNMG5kGECA8 +g383P3elhn03WGbEEa4MNc3Z4+7c236QI3xWJfNPdUbXRaAwhy/6rTSFbzwKB0Jm +ebwzQfwjQY6f55MiI/RqDCyuPj3r3jyVRkK86pQKBAJwFHyqj9KaKXMZjfVnowLh +9svIGfNbGHpucATqREvUHuQbNnqkCx8VVhtYkhDb9fEP2xBu5VvHbR+3nfVhMut5 +G34Ct5RS7Jt6LIfFdtcn8CaSas/l1HbiGeRgc70X/9aYx/V/CEJv0lIe8gP6uDoW +FPIZ7d6vH+Vro6xuWEGiuMaiznap2KhZmpkgfupyFmplh0s6knymuQINBFit2ioB +EADneL9S9m4vhU3blaRjVUUyJ7b/qTjcSylvCH5XUE6R2k+ckEZjfAMZPLpO+/tF +M2JIJMD4SifKuS3xck9KtZGCufGmcwiLQRzeHF7vJUKrLD5RTkNi23ydvWZgPjtx +Q+DTT1Zcn7BrQFY6FgnRoUVIxwtdw1bMY/89rsFgS5wwuMESd3Q2RYgb7EOFOpnu +w6da7WakWf4IhnF5nsNYGDVaIHzpiqCl+uTbf1epCjrOlIzkZ3Z3Yk5CM/TiFzPk +z2lLz89cpD8U+NtCsfagWWfjd2U3jDapgH+7nQnCEWpROtzaKHG6lA3pXdix5zG8 +eRc6/0IbUSWvfjKxLLPfNeCS2pCL3IeEI5nothEEYdQH6szpLog79xB9dVnJyKJb +VfxXnseoYqVrRz2VVbUI5Blwm6B40E3eGVfUQWiux54DspyVMMk41Mx7QJ3iynIa +1N4ZAqVMAEruyXTRTxc9XW0tYhDMA/1GYvz0EmFpm8LzTHA6sFVtPm/ZlNCX6P1X +zJwrv7DSQKD6GGlBQUX+OeEJ8tTkkf8QTJSPUdh8P8YxDFS5EOGAvhhpMBYD42kQ +pqXjEC+XcycTvGI7impgv9PDY1RCC1zkBjKPa120rNhv/hkVk/YhuGoajoHyy4h7 +ZQopdcMtpN2dgmhEegny9JCSwxfQmQ0zK0g7m6SHiKMwjwARAQABiQQ+BBgBCAAJ +BQJYrdoqAhsCAikJEI2BgDwOv82IwV0gBBkBCAAGBQJYrdoqAAoJEH6gqcPyc/zY +1WAP/2wJ+R0gE6qsce3rjaIz58PJmc8goKrir5hnElWhPgbq7cYIsW5qiFyLhkdp +YcMmhD9mRiPpQn6Ya2w3e3B8zfIVKipbMBnke/ytZ9M7qHmDCcjoiSmwEXN3wKYI +mD9VHONsl/CG1rU9Isw1jtB5g1YxuBA7M/m36XN6x2u+NtNMDB9P56yc4gfsZVES +KA9v+yY2/l45L8d/WUkUi0YXomn6hyBGI7JrBLq0CX37GEYP6O9rrKipfz73XfO7 +JIGzOKZlljb/D9RX/g7nRbCn+3EtH7xnk+TK/50euEKw8SMUg147sJTcpQmv6UzZ +cM4JgL0HbHVCojV4C/plELwMddALOFeYQzTif6sMRPf+3DSj8frbInjChC3yOLy0 +6br92KFom17EIj2CAcoeq7UPhi2oouYBwPxh5ytdehJkoo+sN7RIWua6P2WSmon5 +U888cSylXC0+ADFdgLX9K2zrDVYUG1vo8CX0vzxFBaHwN6Px26fhIT1/hYUHQR1z +VfNDcyQmXqkOnZvvoMfz/Q0s9BhFJ/zU6AgQbIZE/hm1spsfgvtsD1frZfygXJ9f +irP+MSAI80xHSf91qSRZOj4Pl3ZJNbq4yYxv0b1pkMqeGdjdCYhLU+LZ4wbQmpCk +SVe2prlLureigXtmZfkqevRz7FrIZiu9ky8wnCAPwC7/zmS18rgP/17bOtL4/iIz +QhxAAoAMWVrGyJivSkjhSGx1uCojsWfsTAm11P7jsruIL61ZzMUVE2aM3Pmj5G+W +9AcZ58Em+1WsVnAXdUR//bMmhyr8wL/G1YO1V3JEJTRdxsSxdYa4deGBBY/Adpsw +24jxhOJR+lsJpqIUeb999+R8euDhRHG9eFO7DRu6weatUJ6suupoDTRWtr/4yGqe +dKxV3qQhNLSnaAzqW/1nA3iUB4k7kCaKZxhdhDbClf9P37qaRW467BLCVO/coL3y +Vm50dwdrNtKpMBh3ZpbB1uJvgi9mXtyBOMJ3v8RZeDzFiG8HdCtg9RvIt/AIFoHR +H3S+U79NT6i0KPzLImDfs8T7RlpyuMc4Ufs8ggyg9v3Ae6cN3eQyxcK3w0cbBwsh +/nQNfsA6uu+9H7NhbehBMhYnpNZyrHzCmzyXkauwRAqoCbGCNykTRwsur9gS41TQ +M8ssD1jFheOJf3hODnkKU+HKjvMROl1DK7zdmLdNzA1cvtZH/nCC9KPj1z8QC47S +xx+dTZSx4ONAhwbS/LN3PoKtn8LPjY9NP9uDWI+TWYquS2U+KHDrBDlsgozDbs/O +jCxcpDzNmXpWQHEtHU7649OXHP7UeNST1mCUCH5qdank0V1iejF6/CfTFU4MfcrG +YT90qFF93M3v01BbxP+EIY2/9tiIPbrd +=0YYh +-----END PGP PUBLIC KEY BLOCK----- diff --git a/hack/provisioning/gpg/hashicorp b/hack/provisioning/gpg/hashicorp new file mode 100644 index 00000000000..495865561d5 --- /dev/null +++ b/hack/provisioning/gpg/hashicorp @@ -0,0 +1,64 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGO9u+MBEADmE9i8rpt8xhRqxbzlBG06z3qe+e1DI+SyjscyVVRcGDrEfo+J +W5UWw0+afey7HFkaKqKqOHVVGSjmh6HO3MskxcpRm/pxRzfni/OcBBuJU2DcGXnG +nuRZ+ltqBncOuONi6Wf00McTWviLKHRrP6oWwWww7sYF/RbZp5xGmMJ2vnsNhtp3 +8LIMOmY2xv9LeKMh++WcxQDpIeRohmSJyknbjJ0MNlhnezTIPajrs1laLh/IVKVz +7/Z73UWX+rWI/5g+6yBSEtj368N7iyq+hUvQ/bL00eyg1Gs8nE1xiCmRHdNjMBLX +lHi0V9fYgg3KVGo6Hi/Is2gUtmip4ZPnThVmB5fD5LzS7Y5joYVjHpwUtMD0V3s1 +HiHAUbTH+OY2JqxZDO9iW8Gl0rCLkfaFDBS2EVLPjo/kq9Sn7vfp2WHffWs1fzeB +HI6iUl2AjCCotK61nyMR33rNuNcbPbp+17NkDEy80YPDRbABdgb+hQe0o8htEB2t +CDA3Ev9t2g9IC3VD/jgncCRnPtKP3vhEhlhMo3fUCnJI7XETgbuGntLRHhmGJpTj +ydudopoMWZAU/H9KxJvwlVXiNoBYFvdoxhV7/N+OBQDLMevB8XtPXNQ8ZOEHl22G +hbL8I1c2SqjEPCa27OIccXwNY+s0A41BseBr44dmu9GoQVhI7TsetpR+qwARAQAB +tFFIYXNoaUNvcnAgU2VjdXJpdHkgKEhhc2hpQ29ycCBQYWNrYWdlIFNpZ25pbmcp +IDxzZWN1cml0eStwYWNrYWdpbmdAaGFzaGljb3JwLmNvbT6JAlQEEwEIAD4CGwMF +CwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQR5iuxlTlwVQoyOQu6qFvy8piHnAQUC +Y728PQUJCWYB2gAKCRCqFvy8piHnAd16EADeBtTgkdVEvct40TH/9HKkR/Lc/ohM +rer6FFHdKmceJ6Ma8/Qm4nCO5C7c4+EPjsUXdhK5w8DSdC5VbKLJDY1EnDlmU5B1 +wSFkGoYKoB8lUn30E77E33MTu2kfrSuF605vetq269CyBwIJV7oNN6311dW8iQ6z +IytTtlJbVr4YZ7Vst40/uR4myumk9bVBGEd6JhFAPmr/um+BZFhRf9/8xtOryOyB +GF2d+bc9IoAugpxwv0IowHEqkI4RpK2U9hvxG80sTOcmerOuFbmNyPwnEgtJ6CM1 +bc8WAmObJiQcRSLbcgF+a7+2wqrUbCqRE7QoS2wjd1HpUVPmSdJN925c2uaua2A4 +QCbTEg8kV2HiP0HGXypVNhZJt5ouo0YgR6BSbMlsMHniDQaSIP1LgmEz5xD4UAxO +Y/GRR3LWojGzVzBb0T98jpDgPtOu/NpKx3jhSpE2U9h/VRDiL/Pf7gvEIxPUTKuV +5D8VqAiXovlk4wSH13Q05d9dIAjuinSlxb4DVr8IL0lmx9DyHehticmJVooHDyJl +HoA2q2tFnlBBAFbN92662q8Pqi9HbljVRTD1vUjof6ohaoM+5K1C043dmcwZZMTc +7gV1rbCuxh69rILpjwM1stqgI1ONUIkurKVGZHM6N2AatNKqtBRdGEroQo1aL4+4 +u+DKFrMxOqa5b7kCDQRjvbwTARAA0ut7iKLj9sOcp5kRG/5V+T0Ak2k2GSus7w8e +kFh468SVCNUgLJpLzc5hBiXACQX6PEnyhLZa8RAG+ehBfPt03GbxW6cK9nx7HRFQ +GA79H5B4AP3XdEdT1gIL2eaHdQot0mpF2b07GNfADgj99MhpxMCtTdVbBqHY8YEQ +Uq7+E9UCNNs45w5ddq07EDk+o6C3xdJ42fvS2x44uNH6Z6sdApPXLrybeun74C1Z +Oo4Ypre4+xkcw2q2WIhy0Qzeuw+9tn4CYjrhw/+fvvPGUAhtYlFGF6bSebmyua8Q +MTKhwqHqwJxpjftM3ARdgFkhlH1H+PcmpnVutgTNKGcy+9b/lu/Rjq/47JZ+5VkK +ZtYT/zO1oW5zRklHvB6R/OcSlXGdC0mfReIBcNvuNlLhNcBA9frNdOk3hpJgYDzg +f8Ykkc+4z8SZ9gA3g0JmDHY1X3SnSadSPyMas3zH5W+16rq9E+MZztR0RWwmpDtg +Ff1XGMmvc+FVEB8dRLKFWSt/E1eIhsK2CRnaR8uotKW/A/gosao0E3mnIygcyLB4 +fnOM3mnTF3CcRumxJvnTEmSDcoKSOpv0xbFgQkRAnVSn/gHkcbVw/ZnvZbXvvseh +7dstp2ljCs0queKU+Zo22TCzZqXX/AINs/j9Ll67NyIJev445l3+0TWB0kego5Fi +UVuSWkMAEQEAAYkEcgQYAQgAJhYhBHmK7GVOXBVCjI5C7qoW/LymIecBBQJjvbwT +AhsCBQkJZgGAAkAJEKoW/LymIecBwXQgBBkBCAAdFiEE6wr14plJaVlvmYc+cG5m +g2nAhekFAmO9vBMACgkQcG5mg2nAhenPURAAimI0EBZbqpyHpwpbeYq3Pygg1bdo +IlBQUVoutaN1lR7kqGXwYH+BP6G40x79LwVy/fWV8gO7cDX6D1yeKLNbhnJHPBus +FJDmzDPbjTlyWlDqJoWMiPqfAOc1A1cHodsUJDUlA01j1rPTho0S9iALX5R50Wa9 +sIenpfe7RVunDwW5gw6y8me7ncl5trD0LM2HURw6nYnLrxePiTAF1MF90jrAhJDV ++krYqd6IFq5RHKveRtCuTvpL7DlgVCtntmbXLbVC/Fbv6w1xY3A7rXko/03nswAi +AXHKMP14UutVEcLYDBXbDrvgpb2p2ZUJnujs6cNyx9cOPeuxnke8+ACWvpnWxwjL +M5u8OckiqzRRobNxQZ1vLxzdovYTwTlUAG7QjIXVvOk9VNp/ERhh0eviZK+1/ezk +Z8nnPjx+elThQ+r16EM7hD0RDXtOR1VZ0R3OL64AlZYDZz1jEA3lrGhvbjSIfBQk +T6mxKUsCy3YbElcOyuohmPRgT1iVDIZ/1iPL0Q0HGm4+EsWCdH6fAPB7TlHD8z2D +7JCFLihFDWs5lrZyuWMO9nryZiVjJrOLPcStgJYVd/MhRHR4hC6g09bgo25RMJ6f +gyzL4vlEB7aSUih7yjgL9s5DKXP2J71dAhIlF8nnM403R2xEeHyivnyeR/9Ifn7M +PJvUMUuoG+ZANSMkrw//XA31o//TVk9WsLD1Edxt5XZCoR+fS+Vz8ScLwP1d/vQE +OW/EWzeMRG15C0td1lfHvwPKvf2MN+WLenp9TGZ7A1kEHIpjKvY51AIkX2kW5QLu +Y3LBb+HGiZ6j7AaU4uYR3kS1+L79v4kyvhhBOgx/8V+b3+2pQIsVOp79ySGvVwpL +FJ2QUgO15hnlQJrFLRYa0PISKrSWf35KXAy04mjqCYqIGkLsz2qQCY2lGcD5k05z +bBC4TvxwVxv0ftl2C5Bd0ydl/2YM7GfLrmZmTijK067t4OO+2SROT2oYPDsMtZ6S +E8vUXvoGpQ8tf5Nkrn2t0zDG3UDtgZY5UVYnZI+xT7WHsCz//8fY3QMvPXAuc33T +vVdiSfP0aBnZXj6oGs/4Vl1Dmm62XLr13+SMoepMWg2Vt7C8jqKOmhFmSOWyOmRH +UZJR7nKvTpFnL8atSyFDa4o1bk2U3alOscWS8u8xJ/iMcoONEBhItft6olpMVdzP +CTrnCAqMjTSPlQU/9EGtp21KQBed2KdAsJBYuPgwaQeyNIvQEOXmINavl58VD72Y +2T4TFEY8dUiExAYpSodbwBL2fr8DJxOX68WH6e3fF7HwX8LRBjZq0XUwh0KxgHN+ +b9gGXBvgWnJr4NSQGGPiSQVNNHt2ZcBAClYhm+9eC5/VwB+Etg4+1wDmggztiqE= +=FdUF +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/hack/provisioning/kube/kind.sh b/hack/provisioning/kube/kind.sh index d62afcaf905..3fd1aed52e3 100755 --- a/hack/provisioning/kube/kind.sh +++ b/hack/provisioning/kube/kind.sh @@ -20,13 +20,13 @@ readonly root # shellcheck source=/dev/null . "$root/../../scripts/lib.sh" -GO_VERSION=1.24 -KIND_VERSION=v0.27.0 -CNI_PLUGINS_VERSION=v1.7.1 +GO_VERSION=1.25 +KIND_VERSION=v0.31.0 +CNI_PLUGINS_VERSION=v1.9.0 # shellcheck disable=SC2034 -CNI_PLUGINS_SHA_AMD64=1a28a0506bfe5bcdc981caf1a49eeab7e72da8321f1119b7be85f22621013098 +CNI_PLUGINS_SHA_AMD64=58c03705426e929658f45a851df15a86d06ef680cacbf3f2dc127731ca265c28 # shellcheck disable=SC2034 -CNI_PLUGINS_SHA_ARM64=119fcb508d1ac2149e49a550752f9cd64d023a1d70e189b59c476e4d2bf7c497 +CNI_PLUGINS_SHA_ARM64=2596ef56329dd1269026f46b8df262f09ba43c92dbfb940e1e69fbccccd30a29 [ "$(uname -m)" == "aarch64" ] && GOARCH=arm64 || GOARCH=amd64 diff --git a/hack/scripts/lib.sh b/hack/scripts/lib.sh index 8eb93ca527a..7ce1da9a103 100755 --- a/hack/scripts/lib.sh +++ b/hack/scripts/lib.sh @@ -226,9 +226,10 @@ github::settoken(){ } github::request(){ - local endpoint="$1" + local accept="$1" + local endpoint="$2" local args=( - "Accept: application/vnd.github+json" + "Accept: $accept" "X-GitHub-Api-Version: 2022-11-28" ) @@ -237,21 +238,30 @@ github::request(){ http::get /dev/stdout https://api.github.com/"$endpoint" "${args[@]}" } +github::file(){ + local repo="$1" + local path="$2" + local ref="${3:-main}" + github::request "application/vnd.github.v3.raw" "repos/$repo/contents/$path?ref=$ref" +} + github::tags::latest(){ local repo="$1" - github::request "repos/$repo/tags" | jq -rc .[0].name + github::request "application/vnd.github+json" "repos/$repo/tags" | jq -rc .[0].name } github::releases(){ local repo="$1" - github::request "repos/$repo/releases" | + github::request "application/vnd.github+json" "repos/$repo/releases" | jq -rc .[] } github::releases::latest(){ local repo="$1" - github::request "repos/$repo/releases/latest" | jq -rc . + github::request "application/vnd.github+json" "repos/$repo/releases/latest" | jq -rc . } log::init host::require jq tar curl shasum + +[[ "${1:-}" != "github"* ]] || "$@" diff --git a/hack/test-integration.sh b/hack/test-integration.sh index 3d1a21365a5..cdbeb61957f 100755 --- a/hack/test-integration.sh +++ b/hack/test-integration.sh @@ -26,7 +26,7 @@ if [[ "$(id -u)" = "0" ]]; then fi fi -readonly timeout="60m" +readonly timeout="30m" readonly retries="2" readonly needsudo="${WITH_SUDO:-}" diff --git a/mod/tigron/.golangci.yml b/mod/tigron/.golangci.yml index dfaf0f0c86f..f58a2d37221 100644 --- a/mod/tigron/.golangci.yml +++ b/mod/tigron/.golangci.yml @@ -54,6 +54,10 @@ linters: - sloglint # no slog - testifylint # no testify - zerologlint # no zerolog + - funcorder + - noctx + - noinlineerr + - wsl_v5 settings: interfacebloat: # Default is 10 @@ -91,6 +95,12 @@ linters: - "fmt.Fprint" - "fmt.Fprintln" - "fmt.Fprintf" + - name: redundant-test-main-exit + disabled: true + - name: enforce-switch-style + disabled: true + - name: var-naming + disabled: true depguard: rules: main: diff --git a/mod/tigron/Makefile b/mod/tigron/Makefile index ba2bbf0d754..3613ade3ba8 100644 --- a/mod/tigron/Makefile +++ b/mod/tigron/Makefile @@ -164,15 +164,17 @@ up: ########################## install-dev-tools: $(call title, $@) - # golangci: v2.0.2 (2024-03-26) + # golangci: v2.4.0 (2025-08-14) # git-validation: main (2025-02-25) # ltag: main (2025-03-04) # go-licenses: v2.0.0-alpha.1 (2024-06-27) + # stubbing go-licenses with dependency upgrade due to non-compatibility with golang 1.25rc1 + # Issue: https://github.com/google/go-licenses/issues/312 @cd $(MAKEFILE_DIR) \ - && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@2b224c2cf4c9f261c22a16af7f8ca6408467f338 \ + && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@43d03392d7dc3746fa776dbddd66dfcccff70651 \ && go install github.com/vbatts/git-validation@7b60e35b055dd2eab5844202ffffad51d9c93922 \ && go install github.com/containerd/ltag@66e6a514664ee2d11a470735519fa22b1a9eaabd \ - && go install github.com/google/go-licenses/v2@d01822334fba5896920a060f762ea7ecdbd086e8 + && go install github.com/Shubhranshu153/go-licenses/v2@f8c503d1357dffb6c97ed3b94e912ab294dde24a @echo "Remember to add \$$HOME/go/bin to your path" $(call footer, $@) diff --git a/mod/tigron/expect/comparators.go b/mod/tigron/expect/comparators.go index 84f5fe13bd2..f7b33fe32d6 100644 --- a/mod/tigron/expect/comparators.go +++ b/mod/tigron/expect/comparators.go @@ -15,13 +15,12 @@ */ //revive:disable:package-comments // annoying false positive behavior -//nolint:thelper // FIXME: remove when we move to tig.T + package expect import ( "encoding/json" "regexp" - "testing" "github.com/containerd/nerdctl/mod/tigron/internal/assertive" "github.com/containerd/nerdctl/mod/tigron/test" @@ -30,11 +29,11 @@ import ( // All can be used as a parameter for expected.Output to group a set of comparators. func All(comparators ...test.Comparator) test.Comparator { - return func(stdout, _ string, t *testing.T) { + return func(stdout string, t tig.T) { t.Helper() for _, comparator := range comparators { - comparator(stdout, "", t) + comparator(stdout, t) } } } @@ -42,33 +41,43 @@ func All(comparators ...test.Comparator) test.Comparator { // Contains can be used as a parameter for expected.Output and ensures a comparison string is found contained in the // output. func Contains(compare string, more ...string) test.Comparator { - return func(stdout, _ string, t *testing.T) { - t.Helper() + return func(stdout string, testing tig.T) { + testing.Helper() - assertive.Contains(assertive.WithFailLater(t), stdout, compare, "Inspecting output (contains)") + assertive.Contains(assertive.WithFailLater(testing), stdout, compare, "Inspecting output (contains)") for _, m := range more { - assertive.Contains(assertive.WithFailLater(t), stdout, m, "Inspecting output (contains)") + assertive.Contains(assertive.WithFailLater(testing), stdout, m, "Inspecting output (contains)") } } } // DoesNotContain is to be used for expected.Output to ensure a comparison string is NOT found in the output. func DoesNotContain(compare string, more ...string) test.Comparator { - return func(stdout, _ string, t *testing.T) { - t.Helper() + return func(stdout string, testing tig.T) { + testing.Helper() - assertive.DoesNotContain(assertive.WithFailLater(t), stdout, compare, "Inspecting output (does not contain)") + assertive.DoesNotContain( + assertive.WithFailLater(testing), + stdout, + compare, + "Inspecting output (does not contain)", + ) for _, m := range more { - assertive.DoesNotContain(assertive.WithFailLater(t), stdout, m, "Inspecting output (does not contain)") + assertive.DoesNotContain( + assertive.WithFailLater(testing), + stdout, + m, + "Inspecting output (does not contain)", + ) } } } // Equals is to be used for expected.Output to ensure it is exactly the output. func Equals(compare string) test.Comparator { - return func(stdout, _ string, t *testing.T) { + return func(stdout string, t tig.T) { t.Helper() assertive.IsEqual(assertive.WithFailLater(t), stdout, compare, "Inspecting output (equals)") } @@ -76,23 +85,31 @@ func Equals(compare string) test.Comparator { // Match is to be used for expected.Output to ensure we match a regexp. func Match(reg *regexp.Regexp) test.Comparator { - return func(stdout, _ string, t *testing.T) { + return func(stdout string, t tig.T) { t.Helper() assertive.Match(assertive.WithFailLater(t), stdout, reg, "Inspecting output (match)") } } +// DoesNotMatch returns a comparator verifying the output does not match the provided regexp. +func DoesNotMatch(reg *regexp.Regexp) test.Comparator { + return func(stdout string, t tig.T) { + t.Helper() + assertive.DoesNotMatch(assertive.WithFailLater(t), stdout, reg, "Inspecting output (!match)") + } +} + // JSON allows to verify that the output can be marshalled into T, and optionally can be further verified by a provided // method. -func JSON[T any](obj T, verifier func(T, string, tig.T)) test.Comparator { - return func(stdout, _ string, t *testing.T) { - t.Helper() +func JSON[T any](obj T, verifier func(T, tig.T)) test.Comparator { + return func(stdout string, testing tig.T) { + testing.Helper() err := json.Unmarshal([]byte(stdout), &obj) - assertive.ErrorIsNil(assertive.WithSilentSuccess(t), err, "Unmarshalling JSON from stdout must succeed") + assertive.ErrorIsNil(assertive.WithSilentSuccess(testing), err, "Unmarshalling JSON from stdout must succeed") if verifier != nil && err == nil { - verifier(obj, "Inspecting output (JSON)", t) + verifier(obj, testing) } } } diff --git a/mod/tigron/expect/comparators_test.go b/mod/tigron/expect/comparators_test.go index d0d76c3b701..306ebb28018 100644 --- a/mod/tigron/expect/comparators_test.go +++ b/mod/tigron/expect/comparators_test.go @@ -33,10 +33,10 @@ func TestExpect(t *testing.T) { // TODO: write more tests once we can mock t in Comparator signature t.Parallel() - expect.Contains("b")("a b c", "contains works", t) - expect.DoesNotContain("d")("a b c", "does not contain works", t) - expect.Equals("a b c")("a b c", "equals work", t) - expect.Match(regexp.MustCompile("[a-z ]+"))("a b c", "match works", t) + expect.Contains("b")("a b c", t) + expect.DoesNotContain("d")("a b c", t) + expect.Equals("a b c")("a b c", t) + expect.Match(regexp.MustCompile("[a-z ]+"))("a b c", t) expect.All( expect.Contains("b"), @@ -45,7 +45,7 @@ func TestExpect(t *testing.T) { expect.DoesNotContain("d", "e"), expect.Equals("a b c"), expect.Match(regexp.MustCompile("[a-z ]+")), - )("a b c", "all", t) + )("a b c", t) type foo struct { Foo map[string]string `json:"foo"` @@ -59,9 +59,9 @@ func TestExpect(t *testing.T) { assertive.ErrorIsNil(t, err) - expect.JSON(&foo{}, nil)(string(data), "json, no verifier", t) + expect.JSON(&foo{}, nil)(string(data), t) - expect.JSON(&foo{}, func(obj *foo, info string, t tig.T) { - assertive.IsEqual(t, obj.Foo["foo"], "bar", info) - })(string(data), "json, with verifier", t) + expect.JSON(&foo{}, func(obj *foo, t tig.T) { + assertive.IsEqual(t, obj.Foo["foo"], "bar") + })(string(data), t) } diff --git a/mod/tigron/expect/doc.md b/mod/tigron/expect/doc.md index 566f92d8c55..c8fa0b71c8f 100644 --- a/mod/tigron/expect/doc.md +++ b/mod/tigron/expect/doc.md @@ -58,7 +58,7 @@ The following ready-made `test.Comparator` generators are provided: - `expect.Equals(string)`: strict equality - `expect.Match(*regexp.Regexp)`: regexp matching - `expect.All(comparators ...Comparator)`: allows to bundle together a bunch of other comparators -- `expect.JSON[T any](obj T, verifier func(T, string, tig.T))`: allows to verify the output is valid JSON and optionally +- `expect.JSON[T any](obj T, verifier func(T, tig.T))`: allows to verify the output is valid JSON and optionally pass `verifier(T, string, tig.T)` extra validation ### A complete example @@ -93,8 +93,8 @@ func TestMyThing(t *testing.T) { expect.All( expect.Contains("out"), expect.DoesNotContain("something"), - expect.JSON(&Thing{}, func(obj *Thing, info string, t tig.T) { - assert.Equal(t, obj.Name, "something", info) + expect.JSON(&Thing{}, func(obj *Thing, t tig.T) { + assert.Equal(t, obj.Name, "something") }), ), ) @@ -131,7 +131,7 @@ func TestMyThing(t *testing.T) { myTest.Command = test.Custom("ls") // Set your expectations - myTest.Expected = test.Expects(0, nil, func(stdout, info string, t tig.T){ + myTest.Expected = test.Expects(0, nil, func(stdout string, t tig.T){ t.Helper() // Bla bla, do whatever advanced stuff and some asserts }) @@ -143,7 +143,7 @@ func TestMyThing(t *testing.T) { // You can of course generalize your comparator into a generator if it is going to be useful repeatedly func MyComparatorGenerator(param1, param2 any) test.Comparator { - return func(stdout, info string, t tig.T) { + return func(stdout string, t tig.T) { t.Helper() // Do your thing... // ... @@ -155,10 +155,6 @@ func MyComparatorGenerator(param1, param2 any) test.Comparator { You can now pass along `MyComparator(comparisonString)` as the third parameter of `test.Expects`, or compose it with other comparators using `expect.All(MyComparator(comparisonString), OtherComparator(somethingElse))` -Note that you have access to an opaque `info` string, that provides a brief formatted header message that assert -will use in case of failure to provide context on the error. -You may of course ignore it and write your own message. - ### Advanced expectations You may want to have expectations that contain a certain piece of data that is being used in the command or at @@ -180,6 +176,7 @@ import ( "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/mod/tigron/test" ) @@ -206,11 +203,11 @@ func TestMyThing(t *testing.T) { Errors: []error{ errors.New("foobla"), }, - Output: func(stdout, info string, t tig.T) { + Output: func(stdout string, t tig.T) { t.Helper() // Retrieve the data that was set during the Setup phase. - assert.Assert(t, stdout == data.Labels().Get("sometestdata"), info) + assert.Assert(t, stdout == data.Labels().Get("sometestdata")) }, } } diff --git a/mod/tigron/expect/exit.go b/mod/tigron/expect/exit.go index 4ebdf0df594..897bc16d464 100644 --- a/mod/tigron/expect/exit.go +++ b/mod/tigron/expect/exit.go @@ -19,6 +19,8 @@ package expect const ( // ExitCodeSuccess will ensure that the command effectively ran returned with exit code zero. ExitCodeSuccess = 0 + // ExitCodeSigkill verifies a container exited due to SIGKILL. + ExitCodeSigkill = 137 // ExitCodeGenericFail will verify that the command ran and exited with a non-zero error code. // This does NOT include timeouts, cancellation, or signals. ExitCodeGenericFail = -10 diff --git a/mod/tigron/internal/com/command.go b/mod/tigron/internal/com/command.go index c8cd6c3d21b..869a2589de2 100644 --- a/mod/tigron/internal/com/command.go +++ b/mod/tigron/internal/com/command.go @@ -150,6 +150,7 @@ func (gc *Command) WithPTY(stdin, stdout, stderr bool) { // WithFeeder ensures that the provider function will be executed and its output fed to the command stdin. // WithFeeder, like Feed, can be used multiple times, and writes will be performed sequentially, in order. // This command has no effect if Run has already been called. +// Note that if the `writer` function runs a forever loop, we will deadlock and just Wait() forever on the errgroup. func (gc *Command) WithFeeder(writers ...func() io.Reader) { gc.writers = append(gc.writers, writers...) } diff --git a/mod/tigron/internal/mocks/t.go b/mod/tigron/internal/mocks/t.go index 7665fd4fe3b..281913e0213 100644 --- a/mod/tigron/internal/mocks/t.go +++ b/mod/tigron/internal/mocks/t.go @@ -48,6 +48,9 @@ type ( TTempDirIn struct{} TTempDirOut = string + + TSkipIn []any + TSkipOut struct{} ) type MockT struct { @@ -93,3 +96,9 @@ func (m *MockT) TempDir() string { return "" } + +func (m *MockT) Skip(args ...any) { + if handler := m.Retrieve(); handler != nil { + handler.(mimicry.Function[TSkipIn, TSkipOut])(args) + } +} diff --git a/mod/tigron/test/case.go b/mod/tigron/test/case.go index 67846dfbb0f..f4c9c090d16 100644 --- a/mod/tigron/test/case.go +++ b/mod/tigron/test/case.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/internal/assertive" "github.com/containerd/nerdctl/mod/tigron/internal/formatter" + "github.com/containerd/nerdctl/mod/tigron/tig" ) // Case describes an entire test-case, including data, setup and cleanup routines, command and @@ -63,7 +64,7 @@ type Case struct { // Private helpers Helpers - t *testing.T + t tig.T parent *Case } @@ -151,7 +152,7 @@ func (test *Case) Run(t *testing.T) { if test.Require != nil { shouldRun, message := test.Require.Check(test.Data, test.helpers) if !shouldRun { - test.t.Skipf("test skipped as: %s", message) + test.t.Skip("test skipped as: " + message) } if test.Require.Setup != nil { @@ -180,7 +181,7 @@ func (test *Case) Run(t *testing.T) { // Set parallel unless asked not to if !test.NoParallel { - test.t.Parallel() + subT.Parallel() } // Execute cleanups now @@ -197,7 +198,7 @@ func (test *Case) Run(t *testing.T) { } // Register the cleanups, in reverse - test.t.Cleanup(func() { + subT.Cleanup(func() { test.t.Helper() test.t.Log( "\n\n" + formatter.Table( @@ -263,14 +264,14 @@ func (test *Case) Run(t *testing.T) { if len(test.SubTests) > 0 { // Now go for the subtests - test.t.Logf("\n%s️ %q: into subtests prep", subinDecorator, test.t.Name()) + test.t.Log(fmt.Sprintf("\n%s️ %q: into subtests prep", subinDecorator, test.t.Name())) for _, subTest := range test.SubTests { subTest.parent = test - subTest.Run(test.t) + subTest.Run(subT) } - test.t.Logf("\n%s️ %q: done with subtests prep", suboutDecorator, test.t.Name()) + test.t.Log(fmt.Sprintf("\n%s️ %q: done with subtests prep", suboutDecorator, test.t.Name())) } } diff --git a/mod/tigron/test/command.go b/mod/tigron/test/command.go index 23b3365016e..e146b5c9e80 100644 --- a/mod/tigron/test/command.go +++ b/mod/tigron/test/command.go @@ -23,13 +23,13 @@ import ( "os" "strconv" "strings" - "testing" "time" "github.com/containerd/nerdctl/mod/tigron/internal" "github.com/containerd/nerdctl/mod/tigron/internal/assertive" "github.com/containerd/nerdctl/mod/tigron/internal/com" "github.com/containerd/nerdctl/mod/tigron/internal/formatter" + "github.com/containerd/nerdctl/mod/tigron/tig" ) const ( @@ -59,7 +59,7 @@ type CustomizableCommand interface { // default it pass any that is defined by WithEnv WithBlacklist(env []string) // T returns the current testing object - T() *testing.T + T() tig.T // withEnv *copies* the passed map to the environment of the command to be executed // Note that this will override any variable defined in the embedding environment @@ -69,7 +69,7 @@ type CustomizableCommand interface { withTempDir(path string) // WithConfig allows passing custom config properties from the test to the base command withConfig(config Config) - withT(t *testing.T) + withT(t tig.T) // Clear does a clone, but will clear binary and arguments while retaining the env, or any other // custom properties Gotcha: if genericCommand is embedded with a custom Run and an overridden // clear to return the embedding type the result will be the embedding command, no longer the @@ -102,7 +102,7 @@ type GenericCommand struct { TempDir string Env map[string]string - t *testing.T + t tig.T cmd *com.Command async bool @@ -294,7 +294,6 @@ func (gc *GenericCommand) Run(expect *Expected) { if expect.Output != nil { expect.Output( result.Stdout, - "", gc.t, ) } @@ -338,7 +337,7 @@ func (gc *GenericCommand) Clone() TestableCommand { return &clone } -func (gc *GenericCommand) T() *testing.T { +func (gc *GenericCommand) T() tig.T { return gc.t } @@ -362,7 +361,7 @@ func (gc *GenericCommand) clear() TestableCommand { return &comcopy } -func (gc *GenericCommand) withT(t *testing.T) { +func (gc *GenericCommand) withT(t tig.T) { t.Helper() gc.t = t } diff --git a/mod/tigron/test/data.go b/mod/tigron/test/data.go index 9400b88ec03..eabbe81c379 100644 --- a/mod/tigron/test/data.go +++ b/mod/tigron/test/data.go @@ -126,7 +126,7 @@ func (tp *temp) SaveToWriter(writer func(file io.Writer) error, key ...string) s silentT := assertive.WithSilentSuccess(tp.t) //nolint:gosec // it is fine - file, err := os.OpenFile(pth, os.O_CREATE, FilePermissionsDefault) + file, err := os.OpenFile(pth, os.O_CREATE|os.O_WRONLY, FilePermissionsDefault) assertive.ErrorIsNil( silentT, err, diff --git a/mod/tigron/test/funct.go b/mod/tigron/test/funct.go index 45b4abbd9d9..f2be434e851 100644 --- a/mod/tigron/test/funct.go +++ b/mod/tigron/test/funct.go @@ -16,7 +16,9 @@ package test -import "testing" +import ( + "github.com/containerd/nerdctl/mod/tigron/tig" +) // An Evaluator is a function that decides whether a test should run or not. type Evaluator func(data Data, helpers Helpers) (bool, string) @@ -30,7 +32,7 @@ type Butler func(data Data, helpers Helpers) // - move to tig.T // A Comparator is the function signature to implement for the Output property of an Expected. -type Comparator func(stdout, info string, t *testing.T) +type Comparator func(stdout string, t tig.T) // A Manager is the function signature meant to produce expectations for a command. type Manager func(data Data, helpers Helpers) *Expected diff --git a/mod/tigron/test/helpers.go b/mod/tigron/test/helpers.go index c148be6e5ac..ce5c48cbea2 100644 --- a/mod/tigron/test/helpers.go +++ b/mod/tigron/test/helpers.go @@ -17,9 +17,8 @@ package test import ( - "testing" - "github.com/containerd/nerdctl/mod/tigron/internal" + "github.com/containerd/nerdctl/mod/tigron/tig" ) // This is the implementation of Helpers @@ -27,7 +26,7 @@ import ( type helpersInternal struct { cmdInternal CustomizableCommand - t *testing.T + t tig.T } // Ensure will run a command and make sure it is successful. @@ -60,8 +59,7 @@ func (help *helpersInternal) Capture(args ...string) string { help.t.Helper() help.Command(args...).Run(&Expected{ - //nolint:thelper - Output: func(stdout, _ string, _ *testing.T) { + Output: func(stdout string, _ tig.T) { ret = stdout }, }) @@ -104,6 +102,6 @@ func (help *helpersInternal) Write(key ConfigKey, value ConfigValue) { help.cmdInternal.write(key, value) } -func (help *helpersInternal) T() *testing.T { +func (help *helpersInternal) T() tig.T { return help.t } diff --git a/mod/tigron/test/interfaces.go b/mod/tigron/test/interfaces.go index 12df876747a..c7fefc95eb0 100644 --- a/mod/tigron/test/interfaces.go +++ b/mod/tigron/test/interfaces.go @@ -19,8 +19,9 @@ package test import ( "io" "os" - "testing" "time" + + "github.com/containerd/nerdctl/mod/tigron/tig" ) // DataLabels holds key-value test information set by the test authors. @@ -93,7 +94,7 @@ type Helpers interface { Write(key ConfigKey, value ConfigValue) // T returns the current testing object. - T() *testing.T + T() tig.T } // The TestableCommand interface represents a low-level command to execute, typically to be compared diff --git a/mod/tigron/test/test.go b/mod/tigron/test/test.go index 274a783b8b7..e83f05f86ed 100644 --- a/mod/tigron/test/test.go +++ b/mod/tigron/test/test.go @@ -17,13 +17,13 @@ package test import ( - "testing" + "github.com/containerd/nerdctl/mod/tigron/tig" ) // Testable TODO. type Testable interface { - CustomCommand(testCase *Case, t *testing.T) CustomizableCommand - AmbientRequirements(testCase *Case, t *testing.T) + CustomCommand(testCase *Case, t tig.T) CustomizableCommand + AmbientRequirements(testCase *Case, t tig.T) } // FIXME diff --git a/mod/tigron/tig/t.go b/mod/tigron/tig/t.go index f6256b72404..68293509731 100644 --- a/mod/tigron/tig/t.go +++ b/mod/tigron/tig/t.go @@ -37,4 +37,5 @@ type T interface { Log(args ...any) Name() string TempDir() string + Skip(args ...any) } diff --git a/mod/tigron/utils/testca/ca.go b/mod/tigron/utils/testca/ca.go index 662be0c810c..4431ca914dc 100644 --- a/mod/tigron/utils/testca/ca.go +++ b/mod/tigron/utils/testca/ca.go @@ -107,7 +107,18 @@ func (ca *Cert) GenerateCustomX509( template *x509.Certificate, ) *Cert { silentT := assertive.WithSilentSuccess(helpers.T()) - key, certPath, keyPath := createCert(silentT, data, underDirectory, template, ca.cert, ca.key) + + var ( + cert *x509.Certificate + key *rsa.PrivateKey + ) + + if ca != nil { + cert = ca.cert + key = ca.key + } + + key, certPath, keyPath := createCert(silentT, data, underDirectory, template, cert, key) return &Cert{ CertPath: certPath, @@ -124,16 +135,16 @@ func createCert( template, caCert *x509.Certificate, caKey *rsa.PrivateKey, ) (key *rsa.PrivateKey, certPath, keyPath string) { - if caCert == nil { - caCert = template - } + key, err := rsa.GenerateKey(rand.Reader, keyLength) + assertive.ErrorIsNil(testing, err, "key generation should succeed") if caKey == nil { caKey = key } - key, err := rsa.GenerateKey(rand.Reader, keyLength) - assertive.ErrorIsNil(testing, err, "key generation should succeed") + if caCert == nil { + caCert = template + } signedCert, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) assertive.ErrorIsNil(testing, err, "certificate creation should succeed") @@ -144,16 +155,17 @@ func createCert( } data.Temp().Dir(dir) - certPath = data.Temp().Path(dir, serial.String()+".cert") - keyPath = data.Temp().Path(dir, serial.String()+".key") data.Temp().SaveToWriter(func(writer io.Writer) error { return pem.Encode(writer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - }, keyPath) + }, dir, serial.String()+".key") data.Temp().SaveToWriter(func(writer io.Writer) error { return pem.Encode(writer, &pem.Block{Type: "CERTIFICATE", Bytes: signedCert}) - }, keyPath) + }, dir, serial.String()+".cert") + + certPath = data.Temp().Path(dir, serial.String()+".cert") + keyPath = data.Temp().Path(dir, serial.String()+".key") return key, certPath, keyPath } diff --git a/pkg/api/types/checkpoint_types.go b/pkg/api/types/checkpoint_types.go new file mode 100644 index 00000000000..1cc9f2aea29 --- /dev/null +++ b/pkg/api/types/checkpoint_types.go @@ -0,0 +1,48 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import "io" + +// CheckpointCreateOptions specifies options for `nerdctl checkpoint create`. +type CheckpointCreateOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Leave the container running after checkpointing + LeaveRunning bool + // Checkpoint directory + CheckpointDir string +} + +type CheckpointListOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Checkpoint directory + CheckpointDir string +} + +// CheckpointRemoveOptions specifies options for `nerdctl checkpoint rm`. +type CheckpointRemoveOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Checkpoint directory + CheckpointDir string +} +type CheckpointSummary struct { + // Name is the name of the checkpoint. + Name string +} diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go index 3a7f89b0d5f..ac893c1b080 100644 --- a/pkg/api/types/container_types.go +++ b/pkg/api/types/container_types.go @@ -32,6 +32,14 @@ type ContainerStartOptions struct { DetachKeys string // Attach stdin Interactive bool + // Checkpoint is the name of the checkpoint to restore + Checkpoint string + // CheckpointDir is the directory to store checkpoints + CheckpointDir string + // NerdctlCmd is the command name of nerdctl + NerdctlCmd string + // NerdctlArgs is the arguments of nerdctl + NerdctlArgs []string } // ContainerKillOptions specifies options for `nerdctl (container) kill`. @@ -44,6 +52,13 @@ type ContainerKillOptions struct { KillSignal string } +// ContainerExportOptions specifies options for `nerdctl (container) export`. +type ContainerExportOptions struct { + Stdout io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions +} + // ContainerCreateOptions specifies options for `nerdctl (container) create` and `nerdctl (container) run`. type ContainerCreateOptions struct { Stdout io.Writer @@ -140,7 +155,7 @@ type ContainerCreateOptions struct { OomKillDisable bool // OomScoreAdjChanged specifies whether the OOM preferences has been changed OomScoreAdjChanged bool - // OomScoreAdj specifies the tune container’s OOM preferences (-1000 to 1000, rootless: 100 to 1000) + // OomScoreAdj specifies the tune container's OOM preferences (-1000 to 1000, rootless: 100 to 1000) OomScoreAdj int // PidsLimit specifies the tune container pids limit PidsLimit int64 @@ -237,8 +252,6 @@ type ContainerCreateOptions struct { // #endregion // #region for metadata flags - // NameChanged specifies whether the name has been changed - NameChanged bool // Name assign a name to the container Name string // Label set meta data on a container @@ -286,6 +299,14 @@ type ContainerCreateOptions struct { // ImagePullOpt specifies image pull options which holds the ImageVerifyOptions for verifying the image. ImagePullOpt ImagePullOptions + // Healthcheck related fields + HealthCmd string + HealthInterval time.Duration + HealthTimeout time.Duration + HealthRetries int + HealthStartPeriod time.Duration + NoHealthcheck bool + // UserNS name for user namespace mapping of container UserNS string } @@ -312,6 +333,10 @@ type ContainerRestartOptions struct { Timeout *time.Duration // Signal to send to stop the container, before sending SIGKILL Signal string + // NerdctlCmd is the command name of nerdctl + NerdctlCmd string + // NerdctlArgs is the arguments of nerdctl + NerdctlArgs []string } // ContainerPauseOptions specifies options for `nerdctl (container) pause`. @@ -329,7 +354,14 @@ type ContainerPruneOptions struct { } // ContainerUnpauseOptions specifies options for `nerdctl (container) unpause`. -type ContainerUnpauseOptions ContainerPauseOptions +type ContainerUnpauseOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // NerdctlCmd is the command name of nerdctl + NerdctlCmd string + // NerdctlArgs is the arguments of nerdctl + NerdctlArgs []string +} // ContainerRemoveOptions specifies options for `nerdctl (container) rm`. type ContainerRemoveOptions struct { @@ -385,8 +417,32 @@ type ContainerCommitOptions struct { Change []string // Pause container during commit Pause bool + // Compression is set commit compression algorithm + Compression CompressionType + // Format specifies the image format for the committed image (docker or oci) + Format ImageFormat + // Embed EstargzOptions for eStargz conversion options + EstargzOptions + // Embed ZstdChunkedOptions for zstd:chunked conversion options + ZstdChunkedOptions } +type CompressionType string + +const ( + Zstd CompressionType = "zstd" + Gzip CompressionType = "gzip" +) + +type ImageFormat string + +const ( + // ImageFormatDocker uses Docker Schema2 media types for compatibility + ImageFormatDocker ImageFormat = "docker" + // ImageFormatOCI uses OCI Image Format media types + ImageFormatOCI ImageFormat = "oci" +) + // ContainerDiffOptions specifies options for `nerdctl (container) diff`. type ContainerDiffOptions struct { Stdout io.Writer diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go index d48e6318026..8999d659213 100644 --- a/pkg/api/types/image_types.go +++ b/pkg/api/types/image_types.go @@ -19,7 +19,7 @@ package types import ( "io" - "github.com/opencontainers/image-spec/specs-go/v1" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) // ImageListOptions specifies options for `nerdctl image list`. @@ -67,7 +67,17 @@ type ImageConvertOptions struct { // Format the output using the given Go template, e.g, 'json' Format string - // #region estargz flags + // Embed image format options + EstargzOptions + ZstdOptions + ZstdChunkedOptions + NydusOptions + OverlaybdOptions + SociConvertOptions +} + +// EstargzOptions contains eStargz conversion options +type EstargzOptions struct { // Estargz convert legacy tar(.gz) layers to eStargz for lazy pulling. Should be used in conjunction with '--oci' Estargz bool // EstargzRecordIn read 'ctr-remote optimize --record-out=' record file (EXPERIMENTAL) @@ -82,16 +92,20 @@ type ImageConvertOptions struct { EstargzExternalToc bool // EstargzKeepDiffID convert to esgz without changing diffID (cannot be used in conjunction with '--estargz-record-in'. must be specified with '--estargz-external-toc') EstargzKeepDiffID bool - // #endregion + // EstargzGzipHelper helper command for decompressing layers compressed with gzip. Options: pigz, igzip, or gzip + EstargzGzipHelper string +} - // #region zstd flags +// ZstdOptions contains zstd conversion options +type ZstdOptions struct { // Zstd convert legacy tar(.gz) layers to zstd. Should be used in conjunction with '--oci' Zstd bool // ZstdCompressionLevel zstd compression level ZstdCompressionLevel int - // #endregion +} - // #region zstd:chunked flags +// ZstdChunkedOptions contains zstd:chunked conversion options +type ZstdChunkedOptions struct { // ZstdChunked convert legacy tar(.gz) layers to zstd:chunked for lazy pulling. Should be used in conjunction with '--oci' ZstdChunked bool // ZstdChunkedCompressionLevel zstd compression level @@ -100,9 +114,10 @@ type ImageConvertOptions struct { ZstdChunkedChunkSize int // ZstdChunkedRecordIn read 'ctr-remote optimize --record-out=' record file (EXPERIMENTAL) ZstdChunkedRecordIn string - // #endregion +} - // #region nydus flags +// NydusOptions contains nydus conversion options +type NydusOptions struct { // Nydus convert legacy tar(.gz) layers to nydus for lazy pulling. Should be used in conjunction with '--oci' Nydus bool // NydusBuilderPath the nydus-image binary path, if unset, search in PATH environment @@ -113,9 +128,10 @@ type ImageConvertOptions struct { NydusPrefetchPatterns string // NydusCompressor nydus blob compression algorithm, possible values: `none`, `lz4_block`, `zstd`, default is `lz4_block` NydusCompressor string - // #endregion +} - // #region overlaybd flags +// OverlaybdOptions contains overlaybd conversion options +type OverlaybdOptions struct { // Overlaybd convert tar.gz layers to overlaybd layers Overlaybd bool // OverlayFsType filesystem type for overlaybd @@ -123,7 +139,14 @@ type ImageConvertOptions struct { // OverlaydbDBStr database config string for overlaybd OverlaydbDBStr string // #endregion +} +type SociConvertOptions struct { + // Soci convert image to SOCI format. + Soci bool + // SociOptions contains SOCI-specific options + SociOptions SociOptions + // #endregion } // ImageCryptOptions specifies options for `nerdctl image encrypt` and `nerdctl image decrypt`. @@ -200,7 +223,7 @@ type ImagePullOptions struct { // If nil, it will unpack automatically if only 1 platform is specified. Unpack *bool // Content for specific platforms. Empty if `--all-platforms` is true - OCISpecPlatform []v1.Platform + OCISpecPlatform []ocispec.Platform // Pull mode Mode string // Suppress verbose output @@ -289,4 +312,8 @@ type SociOptions struct { SpanSize int64 // Minimum layer size to build zTOC for. Smaller layers won't have zTOC and not lazy pulled. Default is 10 MiB. MinLayerSize int64 + // Platforms convert content for a specific platform + Platforms []string + // AllPlatforms convert content for all platforms + AllPlatforms bool } diff --git a/pkg/api/types/import_types.go b/pkg/api/types/import_types.go new file mode 100644 index 00000000000..e78d03ae92d --- /dev/null +++ b/pkg/api/types/import_types.go @@ -0,0 +1,31 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import "io" + +// ImageImportOptions specifies options for `nerdctl (image) import`. +type ImageImportOptions struct { + Stdout io.Writer + Stdin io.Reader + GOptions GlobalCommandOptions + + Source string + Reference string + Message string + Platform string +} diff --git a/pkg/api/types/manifest_types.go b/pkg/api/types/manifest_types.go new file mode 100644 index 00000000000..0bbf45af651 --- /dev/null +++ b/pkg/api/types/manifest_types.go @@ -0,0 +1,60 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import "io" + +// ManifestAnnotateOptions specifies options for `nerdctl manifest annotate`. +type ManifestAnnotateOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + Os string + Arch string + OsVersion string + Variant string + OsFeatures []string +} + +// ManifestCreateOptions specifies options for `nerdctl manifest create`. +type ManifestCreateOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Amend an existing manifest list + Amend bool + // Allow communication with an insecure registry + Insecure bool +} + +// ManifestInspectOptions specifies options for `nerdctl manifest inspect`. +type ManifestInspectOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Verbose output additional info including layers and platform + Verbose bool + // Allow communication with an insecure registry + Insecure bool +} + +// ManifestPushOptions specifies options for `nerdctl manifest push`. +type ManifestPushOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Allow communication with an insecure registry + Insecure bool + // Remove the manifest list after pushing + Purge bool +} diff --git a/pkg/api/types/namespace_types.go b/pkg/api/types/namespace_types.go index c3e8d2c4b08..23b7814dd9e 100644 --- a/pkg/api/types/namespace_types.go +++ b/pkg/api/types/namespace_types.go @@ -43,3 +43,13 @@ type NamespaceInspectOptions struct { // Format the output using the given Go template, e.g, '{{json .}}' Format string } + +// NamespaceListOptions specifies options for `nerdctl namespace ls`. +type NamespaceListOptions struct { + Stdout io.Writer + GOptions GlobalCommandOptions + // Format the output using the given Go template, e.g, '{{json .}}' + Format string + // Quiet suppresses extra information and only prints namespace names + Quiet bool +} diff --git a/pkg/api/types/network_types.go b/pkg/api/types/network_types.go index 5cb26b3ea15..530f66fa729 100644 --- a/pkg/api/types/network_types.go +++ b/pkg/api/types/network_types.go @@ -35,6 +35,7 @@ type NetworkCreateOptions struct { IPRange string Labels []string IPv6 bool + Internal bool } // NetworkInspectOptions specifies options for `nerdctl network inspect`. diff --git a/pkg/api/types/search_types.go b/pkg/api/types/search_types.go new file mode 100644 index 00000000000..645335a72c3 --- /dev/null +++ b/pkg/api/types/search_types.go @@ -0,0 +1,36 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import ( + "io" +) + +type SearchOptions struct { + Stdout io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions + + // NoTrunc don't truncate output + NoTrunc bool + // Limit the number of results + Limit int + // Filter output based on conditions provided, for the --filter argument + Filters []string + // Format the output using the given Go template, e.g, '{{json .}}' + Format string +} diff --git a/pkg/apparmorutil/apparmorutil_linux.go b/pkg/apparmorutil/apparmorutil_linux.go index 2a526b81bfb..92fdf3cc684 100644 --- a/pkg/apparmorutil/apparmorutil_linux.go +++ b/pkg/apparmorutil/apparmorutil_linux.go @@ -26,6 +26,8 @@ import ( "github.com/moby/sys/userns" "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) var ( @@ -55,7 +57,7 @@ func hostSupports() bool { return } var buf []byte - buf, err = os.ReadFile("/sys/module/apparmor/parameters/enabled") + buf, err = filesystem.ReadFile("/sys/module/apparmor/parameters/enabled") appArmorSupported = err == nil && len(buf) == 2 && string(buf) == "Y\n" }) return appArmorSupported @@ -88,7 +90,7 @@ var ( // Related: https://gitlab.com/apparmor/apparmor/-/blob/v3.0.3/libraries/libapparmor/src/kernel.c#L311 func CanApplyExistingProfile() bool { paramEnabledOnce.Do(func() { - buf, err := os.ReadFile("/sys/module/apparmor/parameters/enabled") + buf, err := filesystem.ReadFile("/sys/module/apparmor/parameters/enabled") paramEnabled = err == nil && len(buf) == 2 && string(buf) == "Y\n" }) return paramEnabled @@ -132,7 +134,7 @@ func Profiles() ([]Profile, error) { res := make([]Profile, len(ents)) for i, ent := range ents { namePath := filepath.Join(profilesPath, ent.Name(), "name") - b, err := os.ReadFile(namePath) + b, err := filesystem.ReadFile(namePath) if err != nil { log.L.WithError(err).Warnf("failed to read %q", namePath) continue diff --git a/pkg/buildkitutil/buildkitutil.go b/pkg/buildkitutil/buildkitutil.go index 5b9570a1ddb..10ed05379b1 100644 --- a/pkg/buildkitutil/buildkitutil.go +++ b/pkg/buildkitutil/buildkitutil.go @@ -39,6 +39,7 @@ import ( "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) @@ -59,6 +60,13 @@ func BuildctlBaseArgs(buildkitHost string) []string { } func GetBuildkitHost(namespace string) (string, error) { + if buildkitHost := os.Getenv("BUILDKIT_HOST"); buildkitHost != "" { + if _, err := pingBKDaemon(buildkitHost); err != nil { + return "", err + } + return buildkitHost, nil + } + paths, err := getBuildkitHostCandidates(namespace) if err != nil { return "", err @@ -196,11 +204,11 @@ func BuildKitFile(dir, inputfile string) (absDir string, file string, err error) _, cErr := os.Lstat(filepath.Join(absDir, ContainerfileName)) if dErr == nil && cErr == nil { // both files exist, prefer Dockerfile. - dockerfile, err := os.ReadFile(filepath.Join(absDir, DefaultDockerfileName)) + dockerfile, err := filesystem.ReadFile(filepath.Join(absDir, DefaultDockerfileName)) if err != nil { return "", "", err } - containerfile, err := os.ReadFile(filepath.Join(absDir, ContainerfileName)) + containerfile, err := filesystem.ReadFile(filepath.Join(absDir, ContainerfileName)) if err != nil { return "", "", err } diff --git a/pkg/buildkitutil/buildkitutil_test.go b/pkg/buildkitutil/buildkitutil_test.go index a123bc5f3cd..f55f5dd88c1 100644 --- a/pkg/buildkitutil/buildkitutil_test.go +++ b/pkg/buildkitutil/buildkitutil_test.go @@ -29,6 +29,8 @@ import ( "testing" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) func TestBuildKitFile(t *testing.T) { @@ -55,7 +57,7 @@ func TestBuildKitFile(t *testing.T) { { name: "only Dockerfile is present", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, DefaultDockerfileName), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, DefaultDockerfileName), []byte{}, 0644) }, args: args{".", ""}, wantAbsDir: tmp, @@ -65,7 +67,7 @@ func TestBuildKitFile(t *testing.T) { { name: "only Containerfile is present", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{}, 0644) }, args: args{".", ""}, wantAbsDir: tmp, @@ -75,11 +77,11 @@ func TestBuildKitFile(t *testing.T) { { name: "both Dockerfile and Containerfile are present", prepare: func(t *testing.T) error { - var err = os.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{}, 0644) + var err = filesystem.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{}, 0644) if err != nil { return err } - return os.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{}, 0644) }, args: args{".", ""}, wantAbsDir: tmp, @@ -89,11 +91,11 @@ func TestBuildKitFile(t *testing.T) { { name: "Dockerfile and Containerfile have different contents", prepare: func(t *testing.T) error { - var err = os.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{'d'}, 0644) + var err = filesystem.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{'d'}, 0644) if err != nil { return err } - return os.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{'c'}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "Containerfile"), []byte{'c'}, 0644) }, args: args{".", ""}, wantAbsDir: tmp, @@ -103,7 +105,7 @@ func TestBuildKitFile(t *testing.T) { { name: "Custom file is specfied", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, "CustomFile"), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "CustomFile"), []byte{}, 0644) }, args: args{".", "CustomFile"}, wantAbsDir: tmp, @@ -113,7 +115,7 @@ func TestBuildKitFile(t *testing.T) { { name: "Absolute path is specified along with custom file", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, "CustomFile"), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "CustomFile"), []byte{}, 0644) }, args: args{tmp, "CustomFile"}, wantAbsDir: tmp, @@ -123,7 +125,7 @@ func TestBuildKitFile(t *testing.T) { { name: "Absolute path is specified along with Docker file", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte{}, 0644) }, args: args{tmp, "."}, wantAbsDir: tmp, @@ -133,7 +135,7 @@ func TestBuildKitFile(t *testing.T) { { name: "Absolute path is specified with Container file in the path", prepare: func(t *testing.T) error { - return os.WriteFile(filepath.Join(tmp, ContainerfileName), []byte{}, 0644) + return filesystem.WriteFile(filepath.Join(tmp, ContainerfileName), []byte{}, 0644) }, args: args{tmp, "."}, wantAbsDir: tmp, diff --git a/pkg/checkpointutil/checkpointutil.go b/pkg/checkpointutil/checkpointutil.go new file mode 100644 index 00000000000..c3f789af737 --- /dev/null +++ b/pkg/checkpointutil/checkpointutil.go @@ -0,0 +1,48 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpointutil + +import ( + "fmt" + "os" + "path/filepath" +) + +func GetCheckpointDir(checkpointDir, checkpointID, containerID string, create bool) (string, error) { + checkpointAbsDir := filepath.Join(checkpointDir, checkpointID) + stat, err := os.Stat(checkpointAbsDir) + if create { + switch { + case err == nil && stat.IsDir(): + err = fmt.Errorf("checkpoint with name %s already exists for container %s", checkpointID, containerID) + case err != nil && os.IsNotExist(err): + err = os.MkdirAll(checkpointAbsDir, 0o700) + case err != nil: + err = fmt.Errorf("%s exists and is not a directory", checkpointAbsDir) + } + } else { + switch { + case err != nil: + err = fmt.Errorf("checkpoint %s does not exist for container %s", checkpointID, containerID) + case stat.IsDir(): + err = nil + default: + err = fmt.Errorf("%s exists and is not a directory", checkpointAbsDir) + } + } + return checkpointAbsDir, err +} diff --git a/pkg/cioutil/container_io.go b/pkg/cioutil/container_io.go index c69bfda4888..22dd6b4a0a5 100644 --- a/pkg/cioutil/container_io.go +++ b/pkg/cioutil/container_io.go @@ -85,7 +85,9 @@ func (c *ncio) Close() error { select { case err := <-done: - return err + if err != nil { + lastErr = fmt.Errorf("faied to run cmd.wait: %w", err) + } case <-time.After(binaryIOProcTermTimeout): err := c.cmd.Process.Kill() diff --git a/pkg/cmd/builder/build.go b/pkg/cmd/builder/build.go index c25287bb441..835b2ece8f1 100644 --- a/pkg/cmd/builder/build.go +++ b/pkg/cmd/builder/build.go @@ -41,6 +41,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/buildkitutil" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -110,7 +111,7 @@ func Build(ctx context.Context, client *containerd.Client, options types.Builder if err != nil { return err } - if err := os.WriteFile(options.IidFile, []byte(id), 0644); err != nil { + if err := filesystem.WriteFile(options.IidFile, []byte(id), 0644); err != nil { return err } } @@ -403,6 +404,15 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option for _, s := range strutil.DedupeStrSlice(options.Attest) { optAttestType, optAttestAttrs, _ := strings.Cut(s, ",") if strings.HasPrefix(optAttestType, "type=") { + if strings.HasPrefix(optAttestAttrs, "disabled=") { + disabled, err := strconv.ParseBool(strings.TrimPrefix(optAttestAttrs, "disabled=")) + if err != nil { + return "", nil, false, "", nil, nil, fmt.Errorf("invalid value for attribute \"disabled\"") + } + if disabled { + continue + } + } optAttestType := strings.TrimPrefix(optAttestType, "type=") buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=attest:%s=%s", optAttestType, optAttestAttrs)) } else { @@ -466,7 +476,7 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option } func getDigestFromMetaFile(path string) (string, error) { - data, err := os.ReadFile(path) + data, err := filesystem.ReadFile(path) if err != nil { return "", err } diff --git a/pkg/cmd/checkpoint/create.go b/pkg/cmd/checkpoint/create.go new file mode 100644 index 00000000000..31cd0c8fa31 --- /dev/null +++ b/pkg/cmd/checkpoint/create.go @@ -0,0 +1,139 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/api/types/runc/options" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/archive" + "github.com/containerd/containerd/v2/plugins" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/checkpointutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +func Create(ctx context.Context, client *containerd.Client, containerID string, checkpointName string, options types.CheckpointCreateOptions) error { + var container containerd.Container + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple containers found with provided prefix: %s", found.Req) + } + container = found.Container + return nil + }, + } + + n, err := walker.Walk(ctx, containerID) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("error creating checkpoint for container: %s, no such container", containerID) + } + + info, err := container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get info for container %q: %w", containerID, err) + } + + task, err := container.Task(ctx, nil) + if err != nil { + return fmt.Errorf("failed to get task for container %q: %w", containerID, err) + } + + img, err := task.Checkpoint(ctx, withCheckpointOpts(info.Runtime.Name, !options.LeaveRunning)) + if err != nil { + return err + } + + defer client.ImageService().Delete(ctx, img.Name()) + + cs := client.ContentStore() + + rawIndex, err := content.ReadBlob(ctx, cs, img.Target()) + if err != nil { + return fmt.Errorf("failed to retrieve checkpoint data: %w", err) + } + + var index ocispec.Index + if err := json.Unmarshal(rawIndex, &index); err != nil { + return fmt.Errorf("failed to decode checkpoint data: %w", err) + } + + var cpDesc *ocispec.Descriptor + for _, m := range index.Manifests { + if m.MediaType == images.MediaTypeContainerd1Checkpoint { + cpDesc = &m //nolint:gosec + break + } + } + if cpDesc == nil { + return errors.New("invalid checkpoint") + } + + if options.CheckpointDir == "" { + options.CheckpointDir = filepath.Join(options.GOptions.DataRoot, "checkpoints") + } + targetPath, err := checkpointutil.GetCheckpointDir(options.CheckpointDir, checkpointName, container.ID(), true) + if err != nil { + return err + } + + rat, err := cs.ReaderAt(ctx, *cpDesc) + if err != nil { + return fmt.Errorf("failed to get checkpoint reader: %w", err) + } + defer rat.Close() + + _, err = archive.Apply(ctx, targetPath, content.NewReader(rat)) + if err != nil { + return fmt.Errorf("failed to read checkpoint reader: %w", err) + } + + fmt.Fprintf(options.Stdout, "%s\n", checkpointName) + + return nil +} + +func withCheckpointOpts(rt string, exit bool) containerd.CheckpointTaskOpts { + return func(r *containerd.CheckpointTaskInfo) error { + + switch rt { + case plugins.RuntimeRuncV2: + if r.Options == nil { + r.Options = &options.CheckpointOptions{} + } + opts, _ := r.Options.(*options.CheckpointOptions) + + opts.Exit = exit + } + return nil + } +} diff --git a/pkg/cmd/checkpoint/list.go b/pkg/cmd/checkpoint/list.go new file mode 100644 index 00000000000..b8007d0f5ab --- /dev/null +++ b/pkg/cmd/checkpoint/list.go @@ -0,0 +1,71 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "context" + "fmt" + "os" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/checkpointutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +func List(ctx context.Context, client *containerd.Client, containerID string, options types.CheckpointListOptions) ([]types.CheckpointSummary, error) { + var container containerd.Container + var out []types.CheckpointSummary + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple containers found with provided prefix: %s", found.Req) + } + container = found.Container + return nil + }, + } + + n, err := walker.Walk(ctx, containerID) + if err != nil { + return nil, err + } else if n == 0 { + return nil, fmt.Errorf("error list checkpoint for container: %s, no such container", containerID) + } + + checkpointDir, err := checkpointutil.GetCheckpointDir(options.CheckpointDir, "", container.ID(), false) + if err != nil { + return nil, err + } + + dirs, err := os.ReadDir(checkpointDir) + if err != nil { + return nil, err + } + + for _, d := range dirs { + if !d.IsDir() { + continue + } + out = append(out, types.CheckpointSummary{Name: d.Name()}) + } + + return out, nil +} diff --git a/pkg/cmd/checkpoint/remove.go b/pkg/cmd/checkpoint/remove.go new file mode 100644 index 00000000000..e8ae857f258 --- /dev/null +++ b/pkg/cmd/checkpoint/remove.go @@ -0,0 +1,58 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package checkpoint + +import ( + "context" + "fmt" + "os" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/checkpointutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +func Remove(ctx context.Context, client *containerd.Client, containerID string, checkpointName string, options types.CheckpointRemoveOptions) error { + var container containerd.Container + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple containers found with provided prefix: %s", found.Req) + } + container = found.Container + return nil + }, + } + + n, err := walker.Walk(ctx, containerID) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("error removing checkpoint for container: %s, no such container", containerID) + } + + targetPath, err := checkpointutil.GetCheckpointDir(options.CheckpointDir, checkpointName, container.ID(), false) + if err != nil { + return err + } + + return os.RemoveAll(targetPath) +} diff --git a/pkg/cmd/compose/compose.go b/pkg/cmd/compose/compose.go index ba6e0868af1..fd4e2cfa466 100644 --- a/pkg/cmd/compose/compose.go +++ b/pkg/cmd/compose/compose.go @@ -33,7 +33,9 @@ import ( "github.com/containerd/nerdctl/v2/pkg/cmd/volume" "github.com/containerd/nerdctl/v2/pkg/composer" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/ipfs" "github.com/containerd/nerdctl/v2/pkg/netutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" @@ -136,7 +138,7 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op return err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "api"), []byte(ipfsAddress), 0600); err != nil { + if err := filesystem.WriteFile(filepath.Join(dir, "api"), []byte(ipfsAddress), 0600); err != nil { return err } ipfsPath = dir @@ -155,7 +157,7 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op return err } - return composer.New(options, client) + return composer.New(options, client, (*config.Config)(&globalOptions)) } func imageVerifyOptionsFromCompose(ps *serviceparser.Service) types.ImageVerifyOptions { diff --git a/pkg/cmd/container/attach.go b/pkg/cmd/container/attach.go index 31a1523e9e9..a6295c76cb2 100644 --- a/pkg/cmd/container/attach.go +++ b/pkg/cmd/container/attach.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "io" "golang.org/x/term" @@ -114,9 +115,12 @@ func Attach(ctx context.Context, client *containerd.Client, req string, options } io.Cancel() } - in, err := consoleutil.NewDetachableStdin(con, options.DetachKeys, closer) - if err != nil { - return err + var in io.Reader + if options.Stdin != nil { + in, err = consoleutil.NewDetachableStdin(con, options.DetachKeys, closer) + if err != nil { + return err + } } opt = cio.WithStreams(in, con, nil) } else { diff --git a/pkg/cmd/container/commit.go b/pkg/cmd/container/commit.go index 1e089c7e92c..4b447004247 100644 --- a/pkg/cmd/container/commit.go +++ b/pkg/cmd/container/commit.go @@ -44,11 +44,15 @@ func Commit(ctx context.Context, client *containerd.Client, rawRef string, req s } opts := &commit.Opts{ - Author: options.Author, - Message: options.Message, - Ref: parsedReference.String(), - Pause: options.Pause, - Changes: changes, + Author: options.Author, + Message: options.Message, + Ref: parsedReference.String(), + Pause: options.Pause, + Changes: changes, + Compression: options.Compression, + Format: options.Format, + EstargzOptions: options.EstargzOptions, + ZstdChunkedOptions: options.ZstdChunkedOptions, } walker := &containerwalker.ContainerWalker{ diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 6699f97c00d..064ad89395e 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -25,7 +25,9 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" + "slices" "strconv" "strings" @@ -36,7 +38,6 @@ import ( "github.com/containerd/containerd/v2/core/containers" "github.com/containerd/containerd/v2/pkg/cio" "github.com/containerd/containerd/v2/pkg/oci" - "github.com/containerd/go-cni" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/annotations" @@ -47,17 +48,21 @@ import ( "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" "github.com/containerd/nerdctl/v2/pkg/flagutil" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/idgen" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/imgutil/load" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/ipcutil" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/logging" "github.com/containerd/nerdctl/v2/pkg/maputil" "github.com/containerd/nerdctl/v2/pkg/mountutil" "github.com/containerd/nerdctl/v2/pkg/namestore" + "github.com/containerd/nerdctl/v2/pkg/netutil/networkstore" "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/store" @@ -326,12 +331,25 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } opts = append(opts, umaskOpts...) + if !isHostNetwork(netLabelOpts) { + opts = append(opts, withDefaultUnprivilegedPortSysctl()) + } + rtCOpts, err := generateRuntimeCOpts(options.GOptions.CgroupManager, options.Runtime) if err != nil { return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } cOpts = append(cOpts, rtCOpts...) + // Generate health check config based on CLI flags and image. + healthcheckConfig, err := withHealthcheck(options, ensuredImage) + if err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err + } + if healthcheckConfig != "" { + internalLabels.healthcheck = healthcheckConfig + } + lCOpts, err := withContainerLabels(options.Label, options.LabelFile, ensuredImage) if err != nil { return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err @@ -339,8 +357,7 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa cOpts = append(cOpts, lCOpts...) var containerNameStore namestore.NameStore - if options.Name == "" && !options.NameChanged { - // Automatically set the container name, unless `--name=""` was explicitly specified. + if options.Name == "" { var imageRef string if ensuredImage != nil { imageRef = ensuredImage.Ref @@ -352,15 +369,15 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } options.Name = parsedReference.SuggestContainerName(id) } - if options.Name != "" { - containerNameStore, err = namestore.New(dataStore, options.GOptions.Namespace) - if err != nil { - return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err - } - if err := containerNameStore.Acquire(options.Name, id); err != nil { - return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err - } + + containerNameStore, err = namestore.New(dataStore, options.GOptions.Namespace) + if err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err + } + if err := containerNameStore.Acquire(options.Name, id); err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } + internalLabels.name = options.Name internalLabels.pidFile = options.PidFile @@ -379,6 +396,14 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } cOpts = append(cOpts, ilOpt) + netConf := networkstore.NetworkConfig{ + PortMappings: netLabelOpts.PortMappings, + } + err = portutil.StoreNetworkConfig(dataStore, options.GOptions.Namespace, id, netConf) + if err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), fmt.Errorf("Error writing to network-config.json: %v", err) + } + opts = append(opts, propagateInternalContainerdLabelsToOCIAnnotations(), oci.WithAnnotations(strutil.ConvertKVStringsToMap(options.Annotations))) @@ -543,6 +568,32 @@ func GenerateLogURI(dataStore string) (*url.URL, error) { return cio.LogURIGenerator("binary", selfExe, args) } +func isHostNetwork(netOpts types.NetworkOptions) bool { + return slices.Contains(netOpts.NetworkSlice, "host") +} + +// withDefaultUnprivilegedPortSysctl ensures that containers can bind to +// privileged ports (<1024) without requiring CAP_NET_BIND_SERVICE inside +// the container by defaulting net.ipv4.ip_unprivileged_port_start to 0 +// in the container's network namespace. +func withDefaultUnprivilegedPortSysctl() oci.SpecOpts { + const key = "net.ipv4.ip_unprivileged_port_start" + return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { + if s.Linux == nil { + // NOP, as the target platform is not Linux + return nil + } + if s.Linux.Sysctl == nil { + s.Linux.Sysctl = make(map[string]string) + } + + if _, exists := s.Linux.Sysctl[key]; !exists { + s.Linux.Sysctl[key] = "0" + } + return nil + } +} + func withNerdctlOCIHook(cmd string, args []string) (oci.SpecOpts, error) { if rootlessutil.IsRootless() { detachedNetNS, err := rootlessutil.DetachedNetNS() @@ -678,7 +729,6 @@ type internalLabels struct { networks []string ipAddress string ip6Address string - ports []cni.PortMapping macAddress string dnsServers []string dnsSearchDomains []string @@ -706,6 +756,8 @@ type internalLabels struct { deviceMapping []dockercompat.DeviceMapping user string + + healthcheck string } // WithInternalLabels sets the internal labels for a container. @@ -714,9 +766,7 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO var hostConfigLabel dockercompat.HostConfigLabel var dnsSettings dockercompat.DNSSettings m[labels.Namespace] = internalLabels.namespace - if internalLabels.name != "" { - m[labels.Name] = internalLabels.name - } + m[labels.Name] = internalLabels.name m[labels.Hostname] = internalLabels.hostname m[labels.Domainname] = internalLabels.domainname extraHostsJSON, err := json.Marshal(internalLabels.extraHosts) @@ -730,13 +780,6 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO return nil, err } m[labels.Networks] = string(networksJSON) - if len(internalLabels.ports) > 0 { - portsJSON, err := json.Marshal(internalLabels.ports) - if err != nil { - return nil, err - } - m[labels.Ports] = string(portsJSON) - } if internalLabels.logURI != "" { m[labels.LogURI] = internalLabels.logURI logConfigJSON, err := json.Marshal(internalLabels.logConfig) @@ -831,14 +874,75 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO m[labels.User] = internalLabels.user } + if len(internalLabels.healthcheck) > 0 { + m[labels.HealthCheck] = internalLabels.healthcheck + } + return containerd.WithAdditionalContainerLabels(m), nil } +func withHealthcheck(options types.ContainerCreateOptions, ensuredImage *imgutil.EnsuredImage) (string, error) { + // If explicitly disabled + if options.NoHealthcheck { + hc := &healthcheck.Healthcheck{ + Test: []string{"NONE"}, + } + hcJSON, err := hc.ToJSONString() + if err != nil { + return "", fmt.Errorf("failed to serialize disabled healthcheck config: %w", err) + } + return hcJSON, nil + } + + // Start with health checks in image if present + hc := &healthcheck.Healthcheck{} + if ensuredImage != nil && ensuredImage.ImageConfig.Labels != nil { + if label := ensuredImage.ImageConfig.Labels[labels.HealthCheck]; label != "" { + parsed, err := healthcheck.HealthCheckFromJSON(label) + if err != nil { + return "", fmt.Errorf("failed to parse healthcheck label in image: %w", err) + } + hc = parsed + } + } + + // Apply CLI overrides + if options.HealthCmd != "" { + hc.Test = []string{"CMD-SHELL", options.HealthCmd} + } + if options.HealthInterval != 0 { + hc.Interval = options.HealthInterval + } + if options.HealthTimeout != 0 { + hc.Timeout = options.HealthTimeout + } + if options.HealthRetries != 0 { + hc.Retries = options.HealthRetries + } + if options.HealthStartPeriod != 0 { + hc.StartPeriod = options.HealthStartPeriod + } + + // Apply defaults for any unset values, but only if we have a healthcheck configured + if len(hc.Test) > 0 && hc.Test[0] != "NONE" { + hc.ApplyDefaults() + } + + // If no healthcheck config is set (via CLI or image), return empty string so we skip adding to container config. + if reflect.DeepEqual(hc, &healthcheck.Healthcheck{}) { + return "", nil + } + hcJSON, err := hc.ToJSONString() + if err != nil { + return "", fmt.Errorf("failed to serialize healthcheck config: %w", err) + } + return hcJSON, nil +} + // loadNetOpts loads network options into InternalLabels. func (il *internalLabels) loadNetOpts(opts types.NetworkOptions) { il.hostname = opts.Hostname il.domainname = opts.Domainname - il.ports = opts.PortMappings il.ipAddress = opts.IPAddress il.ip6Address = opts.IP6Address il.networks = opts.NetworkSlice @@ -954,7 +1058,7 @@ func generateLogConfig(dataStore string, id string, logDriver string, logOpt []s } logConfigFilePath := logging.LogConfigFilePath(dataStore, ns, id) - if err = os.WriteFile(logConfigFilePath, logConfigB, 0600); err != nil { + if err = filesystem.WriteFile(logConfigFilePath, logConfigB, 0600); err != nil { return logConfig, err } @@ -1024,15 +1128,13 @@ func generateGcFunc(ctx context.Context, container containerd.Container, ns, id, log.G(ctx).WithError(rmErr).Warnf("failed to remove container %q state dir %q", id, internalLabels.stateDir) } - if name != "" { - var errE error - if containerNameStore, errE = namestore.New(dataStore, ns); errE != nil { - log.G(ctx).WithError(errE).Warnf("failed to instantiate container name store during cleanup for container %q", id) - } - // Double-releasing may happen with containers started with --rm, so, ignore NotFound errors - if errE := containerNameStore.Release(name, id); errE != nil && !errors.Is(errE, store.ErrNotFound) { - log.G(ctx).WithError(errE).Warnf("failed to release container name store for container %q (%s)", name, id) - } + var errE error + if containerNameStore, errE = namestore.New(dataStore, ns); errE != nil { + log.G(ctx).WithError(errE).Warnf("failed to instantiate container name store during cleanup for container %q", id) + } + // Double-releasing may happen with containers started with --rm, so, ignore NotFound errors + if errE := containerNameStore.Release(name, id); errE != nil && !errors.Is(errE, store.ErrNotFound) { + log.G(ctx).WithError(errE).Warnf("failed to release container name store for container %q (%s)", name, id) } } } diff --git a/pkg/cmd/container/create_userns_opts_linux.go b/pkg/cmd/container/create_userns_opts_linux.go index 13f9275801c..1702c8c8d45 100644 --- a/pkg/cmd/container/create_userns_opts_linux.go +++ b/pkg/cmd/container/create_userns_opts_linux.go @@ -324,7 +324,8 @@ func getUserAndGroup(spec string) (user.User, user.Group, error) { parts := strings.Split(spec, ":") if len(parts) > 2 { return user.User{}, user.Group{}, fmt.Errorf("invalid identity mapping format: %s", spec) - } else if len(parts) == 2 && (parts[0] == "" || parts[1] == "") { + } + if len(parts) == 2 && (parts[0] == "" || parts[1] == "") { return user.User{}, user.Group{}, fmt.Errorf("invalid identity mapping format: %s", spec) } diff --git a/pkg/cmd/container/exec.go b/pkg/cmd/container/exec.go index 0c087e63782..c874a4d1087 100644 --- a/pkg/cmd/container/exec.go +++ b/pkg/cmd/container/exec.go @@ -134,6 +134,10 @@ func execActionWithContainer(ctx context.Context, client *containerd.Client, con return nil } status := <-statusC + + process.IO().Wait() + process.IO().Close() + code, _, err := status.Result() if err != nil { return err diff --git a/pkg/cmd/container/export.go b/pkg/cmd/container/export.go new file mode 100644 index 00000000000..57d457cd239 --- /dev/null +++ b/pkg/cmd/container/export.go @@ -0,0 +1,151 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + "fmt" + "os" + "runtime" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/archive" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" +) + +// Export exports a container's filesystem as a tar archive +func Export(ctx context.Context, client *containerd.Client, containerReq string, options types.ContainerExportOptions) error { + if runtime.GOOS == "windows" { + return fmt.Errorf("export command is not supported on Windows") + } + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if found.MatchCount > 1 { + return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + return exportContainer(ctx, client, found.Container, options) + }, + } + + n, err := walker.Walk(ctx, containerReq) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("no such container %s", containerReq) + } + return nil +} + +func exportContainer(ctx context.Context, client *containerd.Client, container containerd.Container, options types.ContainerExportOptions) error { + // Get container info to access the snapshot + conInfo, err := container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get container info: %w", err) + } + + // Use the container's snapshot service to get mounts + // This works for both running and stopped containers + sn := client.SnapshotService(conInfo.Snapshotter) + mounts, err := sn.Mounts(ctx, container.ID()) + if err != nil { + return fmt.Errorf("failed to get container mounts: %w", err) + } + + // Create a temporary directory to mount the snapshot + tempDir, err := os.MkdirTemp("", "nerdctl-export-") + if err != nil { + return fmt.Errorf("failed to create temporary mount directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Mount the container's filesystem + err = mount.All(mounts, tempDir) + if err != nil { + return fmt.Errorf("failed to mount container snapshot: %w", err) + } + defer func() { + if unmountErr := mount.Unmount(tempDir, 0); unmountErr != nil { + log.G(ctx).WithError(unmountErr).Warn("Failed to unmount snapshot") + } + }() + + log.G(ctx).Debugf("Mounted container snapshot at %s", tempDir) + + // Create tar archive using WriteDiff + return createTarArchiveWithWriteDiff(ctx, tempDir, options) +} + +func createTarArchiveWithWriteDiff(ctx context.Context, rootPath string, options types.ContainerExportOptions) error { + // Create a temporary empty directory to use as the "before" state for WriteDiff + emptyDir, err := os.MkdirTemp("", "nerdctl-export-empty-") + if err != nil { + return fmt.Errorf("failed to create temporary empty directory: %w", err) + } + defer os.RemoveAll(emptyDir) + + // Debug logging + log.G(ctx).Debugf("Using WriteDiff to export container filesystem from %s", rootPath) + log.G(ctx).Debugf("Empty directory: %s", emptyDir) + log.G(ctx).Debugf("Output writer type: %T", options.Stdout) + + // Check if the rootPath directory exists and has contents + if entries, err := os.ReadDir(rootPath); err != nil { + log.G(ctx).Debugf("Failed to read rootPath directory %s: %v", rootPath, err) + } else { + log.G(ctx).Debugf("RootPath %s contains %d entries", rootPath, len(entries)) + for i, entry := range entries { + if i < 10 { // Only log first 10 entries to avoid spam + log.G(ctx).Debugf(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } + if len(entries) > 10 { + log.G(ctx).Debugf(" ... and %d more entries", len(entries)-10) + } + } + + // Double check that emptyDir is empty + if entries, err := os.ReadDir(emptyDir); err != nil { + log.G(ctx).Debugf("Failed to read emptyDir directory %s: %v", emptyDir, err) + } else { + log.G(ctx).Debugf("EmptyDir %s contains %d entries", emptyDir, len(entries)) + for i, entry := range entries { + if i < 10 { // Only log first 10 entries to avoid spam + log.G(ctx).Debugf(" - %s (dir: %v)", entry.Name(), entry.IsDir()) + } + } + if len(entries) > 10 { + log.G(ctx).Debugf(" ... and %d more entries", len(entries)-10) + } + } + + // Use WriteDiff to create a tar stream comparing the container rootfs (rootPath) + // with an empty directory (emptyDir). This produces a complete export of the container. + err = archive.WriteDiff(ctx, options.Stdout, emptyDir, rootPath) + if err != nil { + return fmt.Errorf("failed to write tar diff: %w", err) + } + + log.G(ctx).Debugf("WriteDiff completed successfully") + + return nil +} diff --git a/pkg/cmd/container/health_check.go b/pkg/cmd/container/health_check.go new file mode 100644 index 00000000000..e2646497c3f --- /dev/null +++ b/pkg/cmd/container/health_check.go @@ -0,0 +1,95 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package container + +import ( + "context" + "fmt" + "time" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/healthcheck" + "github.com/containerd/nerdctl/v2/pkg/labels" +) + +// HealthCheck executes the health check command for a container +func HealthCheck(ctx context.Context, client *containerd.Client, container containerd.Container) error { + // verify container status and get task + task, err := isContainerRunning(ctx, container) + if err != nil { + return err + } + + // Check if container has health check configured + info, err := container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get container info: %w", err) + } + hcConfigJSON, ok := info.Labels[labels.HealthCheck] + if !ok { + return fmt.Errorf("container has no health check configured") + } + + // Parse health check configuration from labels + var hcConfig *healthcheck.Healthcheck + hcConfig, err = healthcheck.HealthCheckFromJSON(hcConfigJSON) + if err != nil { + return fmt.Errorf("invalid health check configuration: %w", err) + } + if hcConfig.Test == nil { + return fmt.Errorf("health check configuration has no test") + } + + // Populate defaults + hcConfig.Interval = timeoutWithDefault(hcConfig.Interval, healthcheck.DefaultProbeInterval) + hcConfig.Timeout = timeoutWithDefault(hcConfig.Timeout, healthcheck.DefaultProbeTimeout) + hcConfig.StartPeriod = timeoutWithDefault(hcConfig.StartPeriod, healthcheck.DefaultStartPeriod) + if hcConfig.Retries == 0 { + hcConfig.Retries = healthcheck.DefaultProbeRetries + } + + // Execute the health check + return healthcheck.ExecuteHealthCheck(ctx, task, container, hcConfig) +} + +func isContainerRunning(ctx context.Context, container containerd.Container) (containerd.Task, error) { + // Get container task to check status + task, err := container.Task(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to get container task: %w", err) + } + + // Check if container is running + status, err := task.Status(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get container status: %w", err) + } + if status.Status != containerd.Running { + return nil, fmt.Errorf("container is not running (status: %s)", status.Status) + } + + return task, nil +} + +// If configuredValue is zero, use defaultValue instead. +func timeoutWithDefault(configuredValue time.Duration, defaultValue time.Duration) time.Duration { + if configuredValue == 0 { + return defaultValue + } + return configuredValue +} diff --git a/pkg/cmd/container/inspect.go b/pkg/cmd/container/inspect.go index 63c359ae51a..f9cdb18308a 100644 --- a/pkg/cmd/container/inspect.go +++ b/pkg/cmd/container/inspect.go @@ -25,19 +25,28 @@ import ( "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerdutil" "github.com/containerd/nerdctl/v2/pkg/containerinspector" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/portutil" ) // Inspect prints detailed information for each container in `containers`. func Inspect(ctx context.Context, client *containerd.Client, containers []string, options types.ContainerInspectOptions) ([]any, error) { + dataStore, err := clientutil.DataStore(options.GOptions.DataRoot, options.GOptions.Address) + if err != nil { + return []any{}, err + } + f := &containerInspector{ mode: options.Mode, size: options.Size, snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), + dataStore: dataStore, + namespace: options.GOptions.Namespace, } walker := &containerwalker.ContainerWalker{ @@ -45,7 +54,7 @@ func Inspect(ctx context.Context, client *containerd.Client, containers []string OnFound: f.Handler, } - err := walker.WalkAll(ctx, containers, true) + err = walker.WalkAll(ctx, containers, true) if err != nil { return []any{}, err } @@ -58,6 +67,8 @@ type containerInspector struct { size bool snapshotter snapshots.Snapshotter entries []interface{} + dataStore string + namespace string } func (x *containerInspector) Handler(ctx context.Context, found containerwalker.Found) error { @@ -68,6 +79,19 @@ func (x *containerInspector) Handler(ctx context.Context, found containerwalker. if err != nil { return err } + + containerLabels, err := found.Container.Labels(ctx) + if err != nil { + return err + } + ports, err := portutil.LoadPortMappings(x.dataStore, x.namespace, n.ID, containerLabels) + if err != nil { + return err + } + if n.Process != nil && n.Process.NetNS != nil && len(ports) > 0 { + n.Process.NetNS.PortMappings = ports + } + switch x.mode { case "native": x.entries = append(x.entries, n) diff --git a/pkg/cmd/container/kill.go b/pkg/cmd/container/kill.go index 4f750d54784..d42a7cd8c82 100644 --- a/pkg/cmd/container/kill.go +++ b/pkg/cmd/container/kill.go @@ -33,7 +33,9 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/netutil" @@ -110,6 +112,11 @@ func killContainer(ctx context.Context, container containerd.Container, signal s return err } + // Clean up healthcheck systemd units + if err := healthcheck.RemoveTransientHealthCheckFiles(ctx, container); err != nil { + log.G(ctx).Warnf("failed to clean up healthcheck units for container %s: %s", container.ID(), err) + } + // signal will be sent once resume is finished if paused { if err := task.Resume(ctx); err != nil { @@ -122,14 +129,18 @@ func killContainer(ctx context.Context, container containerd.Container, signal s // cleanupNetwork removes cni network setup, specifically the forwards func cleanupNetwork(ctx context.Context, container containerd.Container, globalOpts types.GlobalCommandOptions) error { return rootlessutil.WithDetachedNetNSIfAny(func() error { - // retrieve info to get current active port mappings - info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata) + // retrieve current active port mappings + dataStore, err := clientutil.DataStore(globalOpts.DataRoot, globalOpts.Address) + if err != nil { + return err + } + containerLabels, err := container.Labels(ctx) if err != nil { return err } - ports, portErr := portutil.ParsePortsLabel(info.Labels) - if portErr != nil { - return fmt.Errorf("no oci spec: %q", portErr) + ports, err := portutil.LoadPortMappings(dataStore, globalOpts.Namespace, container.ID(), containerLabels) + if err != nil { + return fmt.Errorf("no oci spec: %q", err) } portMappings := []cni.NamespaceOpts{ cni.WithCapabilityPortMap(ports), diff --git a/pkg/cmd/container/list.go b/pkg/cmd/container/list.go index b23dbb9e14b..3a1d28269e9 100644 --- a/pkg/cmd/container/list.go +++ b/pkg/cmd/container/list.go @@ -32,11 +32,13 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerdutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/formatter" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/portutil" ) // List prints containers according to `options`. @@ -162,6 +164,18 @@ func prepareContainers(ctx context.Context, client *containerd.Client, container } else { return nil, fmt.Errorf("can't get container %s status", c.ID()) } + dataStore, err := clientutil.DataStore(options.GOptions.DataRoot, options.GOptions.Address) + if err != nil { + return nil, err + } + containerLabels, err := c.Labels(ctx) + if err != nil { + return nil, err + } + ports, err := portutil.LoadPortMappings(dataStore, options.GOptions.Namespace, c.ID(), containerLabels) + if err != nil { + return nil, err + } li := ListItem{ Command: formatter.InspectContainerCommand(spec, options.Truncate, true), CreatedAt: info.CreatedAt, @@ -169,7 +183,7 @@ func prepareContainers(ctx context.Context, client *containerd.Client, container Image: info.Image, Platform: info.Labels[labels.Platform], Names: containerutil.GetContainerName(info.Labels), - Ports: formatter.FormatPorts(info.Labels), + Ports: formatter.FormatPorts(ports), Status: status, Runtime: info.Runtime.Name, Labels: formatter.FormatLabels(info.Labels), diff --git a/pkg/cmd/container/list_util.go b/pkg/cmd/container/list_util.go index da63106efd5..a2fdb0a2892 100644 --- a/pkg/cmd/container/list_util.go +++ b/pkg/cmd/container/list_util.go @@ -19,6 +19,7 @@ package container import ( "context" "fmt" + "regexp" "strconv" "strings" "time" @@ -164,11 +165,15 @@ func (cl *containerFilterContext) foldIDFilter(_ context.Context, filter, value } func (cl *containerFilterContext) foldNameFilter(_ context.Context, filter, value string) error { + re, err := regexp.Compile(value) + if err != nil { + return err + } cl.nameFilterFuncs = append(cl.nameFilterFuncs, func(name string) bool { if value == "" { return true } - return strings.Contains(name, value) + return re.MatchString(name) }) return nil } diff --git a/pkg/cmd/container/remove.go b/pkg/cmd/container/remove.go index 1fedcc50432..b9df2b2acaf 100644 --- a/pkg/cmd/container/remove.go +++ b/pkg/cmd/container/remove.go @@ -34,11 +34,13 @@ import ( "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" "github.com/containerd/nerdctl/v2/pkg/ipcutil" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" "github.com/containerd/nerdctl/v2/pkg/namestore" + "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/store" ) @@ -178,6 +180,11 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions // Otherwise, nil the error so that we do not write the error label on the container retErr = nil + // Clean up healthcheck systemd units + if err := healthcheck.RemoveTransientHealthCheckFiles(ctx, c); err != nil { + log.G(ctx).WithError(err).Warnf("failed to clean up healthcheck units for container %q", id) + } + // Now, delete the actual container var delOpts []containerd.DeleteOpts if _, err := c.Image(ctx); err == nil { @@ -191,6 +198,18 @@ func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions } netOpts, err := containerutil.NetworkOptionsFromSpec(spec) + if err != nil { + retErr = err + return + } + + portSlice, err := portutil.LoadPortMappings(dataStore, globalOptions.Namespace, id, containerLabels) + if err != nil { + retErr = err + return + } + netOpts.PortMappings = portSlice + if err == nil { networkManager, err := containerutil.NewNetworkingOptionsManager(globalOptions, netOpts, client) if err != nil { diff --git a/pkg/cmd/container/restart.go b/pkg/cmd/container/restart.go index 3b376ada5a5..17a7bec99e1 100644 --- a/pkg/cmd/container/restart.go +++ b/pkg/cmd/container/restart.go @@ -21,10 +21,13 @@ import ( "fmt" containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels/k8slabels" ) // Restart will restart one or more containers. @@ -35,13 +38,21 @@ func Restart(ctx context.Context, client *containerd.Client, containers []string if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } + info, err := found.Container.Info(ctx) + if err != nil { + return fmt.Errorf("can't get container %s info ", found.Container.ID()) + } + if _, ok := info.Labels[k8slabels.ContainerType]; ok { + log.L.Warnf("nerdctl does not support restarting container %s created by Kubernetes", info.ID) + } if err := containerutil.Stop(ctx, found.Container, options.Timeout, options.Signal); err != nil { return err } - if err := containerutil.Start(ctx, found.Container, false, false, client, ""); err != nil { + + if err := containerutil.Start(ctx, found.Container, false, false, client, "", "", (*config.Config)(&options.GOption), options.NerdctlCmd, options.NerdctlArgs); err != nil { return err } - _, err := fmt.Fprintln(options.Stdout, found.Req) + _, err = fmt.Fprintln(options.Stdout, found.Req) return err }, } diff --git a/pkg/cmd/container/run_linux.go b/pkg/cmd/container/run_linux.go index 3280d3e532d..851307d905e 100644 --- a/pkg/cmd/container/run_linux.go +++ b/pkg/cmd/container/run_linux.go @@ -25,7 +25,6 @@ import ( "github.com/opencontainers/runtime-spec/specs-go" containerd "github.com/containerd/containerd/v2/client" - "github.com/containerd/containerd/v2/contrib/nvidia" "github.com/containerd/containerd/v2/core/containers" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/log" @@ -99,7 +98,7 @@ func setPlatformOptions(ctx context.Context, client *containerd.Client, id, uts if options.Sysctl != nil { opts = append(opts, WithSysctls(strutil.ConvertKVStringsToMap(options.Sysctl))) } - gpuOpt, err := parseGPUOpts(options.GPUs) + gpuOpt, err := parseGPUOpts(options.GOptions.CDISpecDirs, options.GPUs) if err != nil { return nil, err } @@ -262,60 +261,36 @@ func withOOMScoreAdj(score int) oci.SpecOpts { } } -func parseGPUOpts(value []string) (res []oci.SpecOpts, _ error) { +func parseGPUOpts(cdiSpecDirs []string, value []string) (res []oci.SpecOpts, _ error) { for _, gpu := range value { - gpuOpt, err := parseGPUOpt(gpu) + req, err := ParseGPUOptCSV(gpu) if err != nil { return nil, err } - res = append(res, gpuOpt) + res = append(res, withCDIDevices(cdiSpecDirs, req.toCDIDeviceIDS()...)) } return res, nil } -func parseGPUOpt(value string) (oci.SpecOpts, error) { - req, err := ParseGPUOptCSV(value) - if err != nil { - return nil, err +func (req *GPUReq) toCDIDeviceIDS() []string { + var cdiDeviceIDs []string + for _, id := range req.normalizeDeviceIDs() { + cdiDeviceIDs = append(cdiDeviceIDs, "nvidia.com/gpu="+id) } + return cdiDeviceIDs +} - var gpuOpts []nvidia.Opts - +func (req *GPUReq) normalizeDeviceIDs() []string { if len(req.DeviceIDs) > 0 { - gpuOpts = append(gpuOpts, nvidia.WithDeviceUUIDs(req.DeviceIDs...)) - } else if req.Count > 0 { - var devices []int - for i := 0; i < req.Count; i++ { - devices = append(devices, i) - } - gpuOpts = append(gpuOpts, nvidia.WithDevices(devices...)) - } else if req.Count < 0 { - gpuOpts = append(gpuOpts, nvidia.WithAllDevices) + return req.DeviceIDs } - - str2cap := make(map[string]nvidia.Capability) - for _, c := range nvidia.AllCaps() { - str2cap[string(c)] = c - } - var nvidiaCaps []nvidia.Capability - for _, c := range req.Capabilities { - if cp, isNvidiaCap := str2cap[c]; isNvidiaCap { - nvidiaCaps = append(nvidiaCaps, cp) - } + if req.Count < 0 { + return []string{"all"} } - if len(nvidiaCaps) != 0 { - gpuOpts = append(gpuOpts, nvidia.WithCapabilities(nvidiaCaps...)) - } else { - // Add "utility", "compute" capability if unset. - // Please see also: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/user-guide.html#driver-capabilities - gpuOpts = append(gpuOpts, nvidia.WithCapabilities(nvidia.Utility, nvidia.Compute)) - } - - if rootlessutil.IsRootless() { - // "--no-cgroups" option is needed to nvidia-container-cli in rootless environment - // Please see also: https://github.com/moby/moby/issues/38729#issuecomment-463493866 - gpuOpts = append(gpuOpts, nvidia.WithNoCgroups) + var ids []string + for i := 0; i < req.Count; i++ { + ids = append(ids, fmt.Sprintf("%d", i)) } - return nvidia.WithGPUs(gpuOpts...), nil + return ids } diff --git a/pkg/cmd/container/run_mount.go b/pkg/cmd/container/run_mount.go index 92900a21d59..bd0c4a08645 100644 --- a/pkg/cmd/container/run_mount.go +++ b/pkg/cmd/container/run_mount.go @@ -173,10 +173,20 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm return nil, nil, nil, err } + mm := client.MountManager() + + active, err := mm.Activate(ctx, tempDir, mounts) + if err == nil { + defer mm.Deactivate(ctx, tempDir) + mounts = active.System + } else if !errors.Is(err, errdefs.ErrNotImplemented) { + return nil, nil, nil, fmt.Errorf("failed to activate mounts: %w", err) + } + // windows has additional steps for mounting see // https://github.com/containerd/containerd/commit/791e175c79930a34cfbb2048fbcaa8493fd2c86b - unmounter := func(mountPath string) { - if uerr := mount.Unmount(mountPath, 0); uerr != nil { + unmounter := func(tempDir string) { + if uerr := mount.UnmountMounts(mounts, tempDir, 0); uerr != nil { log.G(ctx).Debugf("Failed to unmount snapshot %q", tempDir) if err == nil { err = uerr diff --git a/pkg/cmd/container/run_runtime.go b/pkg/cmd/container/run_runtime.go index b6d0ac4965e..ac03e7b5b8c 100644 --- a/pkg/cmd/container/run_runtime.go +++ b/pkg/cmd/container/run_runtime.go @@ -18,6 +18,7 @@ package container import ( "context" + "os/exec" "strings" "github.com/opencontainers/runtime-spec/specs-go" @@ -49,8 +50,14 @@ func generateRuntimeCOpts(cgroupManager, runtimeStr string) ([]containerd.NewCon runtimeOpts = nil } } else { - // runtimeStr is a runc binary - runcOpts.BinaryName = runtimeStr + // runtimeStr may be a runc binary - check that it exists + // if it does not, treat it as a runtime + ex, err := exec.LookPath(runtimeStr) + if err != nil { + runtime = runtimeStr + } else { + runcOpts.BinaryName = ex + } } } o := containerd.WithRuntime(runtime, runtimeOpts) diff --git a/pkg/cmd/container/run_security_linux.go b/pkg/cmd/container/run_security_linux.go index 510310f265a..dbd76234c1b 100644 --- a/pkg/cmd/container/run_security_linux.go +++ b/pkg/cmd/container/run_security_linux.go @@ -18,6 +18,8 @@ package container import ( "errors" + "fmt" + "strconv" "strings" "sync" @@ -52,7 +54,7 @@ const ( func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([]oci.SpecOpts, error) { for k := range securityOptsMap { switch k { - case "seccomp", "apparmor", "no-new-privileges", "systempaths", "privileged-without-host-devices": + case "seccomp", "apparmor", "no-new-privileges", "systempaths", "privileged-without-host-devices", "writable-cgroups": default: log.L.Warnf("unknown security-opt: %q", k) } @@ -118,6 +120,15 @@ func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([ if privilegedWithoutHostDevices && !privileged { return nil, errors.New("flag `--security-opt privileged-without-host-devices` can't be used without `--privileged` enabled") } + if value, ok := securityOptsMap["writable-cgroups"]; ok { + writable, err := strconv.ParseBool(value) + if err != nil { + return nil, fmt.Errorf("invalid \"writable-cgroups\" value: %q", value) + } + if writable { + opts = append(opts, oci.WithWriteableCgroupfs) + } + } if privileged { if privilegedWithoutHostDevices { diff --git a/pkg/cmd/container/start.go b/pkg/cmd/container/start.go index b0820d2aa39..7360e258c6a 100644 --- a/pkg/cmd/container/start.go +++ b/pkg/cmd/container/start.go @@ -19,10 +19,13 @@ package container import ( "context" "fmt" + "path/filepath" containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/checkpointutil" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) @@ -32,15 +35,28 @@ func Start(ctx context.Context, client *containerd.Client, reqs []string, option if options.Attach && len(reqs) > 1 { return fmt.Errorf("you cannot start and attach multiple containers at once") } + if options.Checkpoint != "" && len(reqs) > 1 { + return fmt.Errorf("you cannot start multiple containers with checkpoint at once") + } walker := &containerwalker.ContainerWalker{ Client: client, OnFound: func(ctx context.Context, found containerwalker.Found) error { var err error + var checkpointDir string if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - if err := containerutil.Start(ctx, found.Container, options.Attach, options.Interactive, client, options.DetachKeys); err != nil { + if options.Checkpoint != "" { + if options.CheckpointDir == "" { + options.CheckpointDir = filepath.Join(options.GOptions.DataRoot, "checkpoints") + } + checkpointDir, err = checkpointutil.GetCheckpointDir(options.CheckpointDir, options.Checkpoint, found.Container.ID(), false) + if err != nil { + return err + } + } + if err := containerutil.Start(ctx, found.Container, options.Attach, options.Interactive, client, options.DetachKeys, checkpointDir, (*config.Config)(&options.GOptions), options.NerdctlCmd, options.NerdctlArgs); err != nil { return err } if !options.Attach { diff --git a/pkg/cmd/container/stats.go b/pkg/cmd/container/stats.go index 8382fb4b241..e69074a6c8f 100644 --- a/pkg/cmd/container/stats.go +++ b/pkg/cmd/container/stats.go @@ -379,6 +379,17 @@ func collect(ctx context.Context, globalOptions types.GlobalCommandOptions, s *s continue } + // Sample system CPU usage close to container usage to avoid + // noise in metric calculations. + systemUsage, onlineCPUs, err := getSystemCPUUsage() + if err != nil { + u <- err + continue + } + systemInfo := statsutil.SystemInfo{ + OnlineCPUs: onlineCPUs, + SystemUsage: systemUsage, + } metric, err := task.Metrics(ctx) if err != nil { u <- err @@ -397,7 +408,7 @@ func collect(ctx context.Context, globalOptions types.GlobalCommandOptions, s *s } // when (firstSet == true), we only set container stats without rendering stat entry - statsEntry, err := setContainerStatsAndRenderStatsEntry(previousStats, firstSet, anydata, int(task.Pid()), netNS.Interfaces) + statsEntry, err := setContainerStatsAndRenderStatsEntry(previousStats, firstSet, anydata, int(task.Pid()), netNS.Interfaces, systemInfo) if err != nil { u <- err continue diff --git a/pkg/cmd/container/stats_linux.go b/pkg/cmd/container/stats_linux.go index 76aa1c96ab3..ee117888e53 100644 --- a/pkg/cmd/container/stats_linux.go +++ b/pkg/cmd/container/stats_linux.go @@ -17,9 +17,13 @@ package container import ( + "bufio" "errors" "fmt" + "io" "net" + "os" + "strconv" "strings" "time" @@ -33,8 +37,17 @@ import ( "github.com/containerd/nerdctl/v2/pkg/statsutil" ) +const ( + // The value comes from `C.sysconf(C._SC_CLK_TCK)`, and + // on Linux it's a constant which is safe to be hard coded, + // so we can avoid using cgo here. For details, see: + // https://github.com/containerd/cgroups/pull/12 + clockTicksPerSecond = 100 + nanoSecondsPerSecond = 1e9 +) + //nolint:nakedret -func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface) (statsEntry statsutil.StatsEntry, err error) { +func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface, systemInfo statsutil.SystemInfo) (statsEntry statsutil.StatsEntry, err error) { var ( data *v1.Metrics @@ -96,10 +109,10 @@ func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStat if data != nil { if !firstSet { - statsEntry, err = statsutil.SetCgroupStatsFields(previousStats, data, nlinks) + statsEntry, err = statsutil.SetCgroupStatsFields(previousStats, data, nlinks, systemInfo) } previousStats.CgroupCPU = data.CPU.Usage.Total - previousStats.CgroupSystem = data.CPU.Usage.Kernel + previousStats.CgroupSystem = systemInfo.SystemUsage if err != nil { return } @@ -117,3 +130,59 @@ func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStat return } + +// getSystemCPUUsage reads the system's CPU usage from /proc/stat and returns +// the total CPU usage in nanoseconds and the number of CPUs. +func getSystemCPUUsage() (cpuUsage uint64, cpuNum uint32, _ error) { + f, err := os.Open("/proc/stat") + if err != nil { + return 0, 0, err + } + defer f.Close() + + return readSystemCPUUsage(f) +} + +// readSystemCPUUsage parses CPU usage information from a reader providing +// /proc/stat format data. It returns the total CPU usage in nanoseconds +// and the number of CPUs. More: +// https://github.com/moby/moby/blob/26db31fdab628a2345ed8f179e575099384166a9/daemon/stats_unix.go#L327-L368 +func readSystemCPUUsage(r io.Reader) (cpuUsage uint64, cpuNum uint32, _ error) { + rdr := bufio.NewReaderSize(r, 1024) + + for { + data, isPartial, err := rdr.ReadLine() + + if err != nil { + return 0, 0, fmt.Errorf("error scanning /proc/stat file: %w", err) + } + // Assume all cpu* records are at the start of the file, like glibc: + // https://github.com/bminor/glibc/blob/5d00c201b9a2da768a79ea8d5311f257871c0b43/sysdeps/unix/sysv/linux/getsysstats.c#L108-L135 + if isPartial || len(data) < 4 { + break + } + line := string(data) + if line[:3] != "cpu" { + break + } + if line[3] == ' ' { + parts := strings.Fields(line) + if len(parts) < 8 { + return 0, 0, fmt.Errorf("invalid number of cpu fields") + } + var totalClockTicks uint64 + for _, i := range parts[1:8] { + v, err := strconv.ParseUint(i, 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("unable to convert value %s to int: %w", i, err) + } + totalClockTicks += v + } + cpuUsage = (totalClockTicks * nanoSecondsPerSecond) / clockTicksPerSecond + } + if '0' <= line[3] && line[3] <= '9' { + cpuNum++ + } + } + return cpuUsage, cpuNum, nil +} diff --git a/pkg/cmd/container/stats_nolinux.go b/pkg/cmd/container/stats_nolinux.go index fbef460eaab..9f644ee1702 100644 --- a/pkg/cmd/container/stats_nolinux.go +++ b/pkg/cmd/container/stats_nolinux.go @@ -23,6 +23,12 @@ import ( "github.com/containerd/nerdctl/v2/pkg/statsutil" ) -func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface) (statsutil.StatsEntry, error) { +func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface, systemInfo statsutil.SystemInfo) (statsutil.StatsEntry, error) { return statsutil.StatsEntry{}, nil } + +// getSystemCPUUsage reads the system's CPU usage from /proc/stat and returns +// the total CPU usage in nanoseconds and the number of CPUs. +func getSystemCPUUsage() (uint64, uint32, error) { + return 0, 0, nil +} diff --git a/pkg/cmd/container/stop.go b/pkg/cmd/container/stop.go index e1f347b6b96..755686e4bd8 100644 --- a/pkg/cmd/container/stop.go +++ b/pkg/cmd/container/stop.go @@ -25,6 +25,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) @@ -39,6 +40,9 @@ func Stop(ctx context.Context, client *containerd.Client, reqs []string, opt typ if err := cleanupNetwork(ctx, found.Container, opt.GOptions); err != nil { return fmt.Errorf("unable to cleanup network for container: %s", found.Req) } + if err := healthcheck.RemoveTransientHealthCheckFiles(ctx, found.Container); err != nil { + return fmt.Errorf("unable to cleanup healthcheck timer for container: %s: %w", found.Req, err) + } if err := containerutil.Stop(ctx, found.Container, opt.Timeout, opt.Signal); err != nil { if errdefs.IsNotFound(err) { fmt.Fprintf(opt.Stderr, "No such container: %s\n", found.Req) diff --git a/pkg/cmd/container/unpause.go b/pkg/cmd/container/unpause.go index cc6f8a5781d..10f8d48e3f5 100644 --- a/pkg/cmd/container/unpause.go +++ b/pkg/cmd/container/unpause.go @@ -23,6 +23,7 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) @@ -35,7 +36,7 @@ func Unpause(ctx context.Context, client *containerd.Client, reqs []string, opti if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - if err := containerutil.Unpause(ctx, client, found.Container.ID()); err != nil { + if err := containerutil.Unpause(ctx, client, found.Container.ID(), (*config.Config)(&options.GOptions), options.NerdctlCmd, options.NerdctlArgs); err != nil { return err } diff --git a/pkg/cmd/image/convert.go b/pkg/cmd/image/convert.go index b7963ea2702..df022b90011 100644 --- a/pkg/cmd/image/convert.go +++ b/pkg/cmd/image/convert.go @@ -41,12 +41,14 @@ import ( estargzexternaltocconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz/externaltoc" zstdchunkedconvert "github.com/containerd/stargz-snapshotter/nativeconverter/zstdchunked" "github.com/containerd/stargz-snapshotter/recorder" + estargzdecompressutil "github.com/containerd/stargz-snapshotter/util/decompressutil" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" converterutil "github.com/containerd/nerdctl/v2/pkg/imgutil/converter" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/snapshotterutil" ) func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRawRef string, options types.ImageConvertOptions) error { @@ -86,8 +88,9 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa zstdchunked := options.ZstdChunked overlaybd := options.Overlaybd nydus := options.Nydus + soci := options.Soci var finalize func(ctx context.Context, cs content.Store, ref string, desc *ocispec.Descriptor) (*images.Image, error) - if estargz || zstd || zstdchunked || overlaybd || nydus { + if estargz || zstd || zstdchunked || overlaybd || nydus || soci { convertCount := 0 if estargz { convertCount++ @@ -104,9 +107,12 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa if nydus { convertCount++ } + if soci { + convertCount++ + } if convertCount > 1 { - return errors.New("options --estargz, --zstdchunked, --overlaybd and --nydus lead to conflict, only one of them can be used") + return errors.New("options --estargz, --zstdchunked, --overlaybd, --nydus and --soci lead to conflict, only one of them can be used") } var convertFunc converter.ConvertFunc @@ -164,6 +170,16 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa )), ) convertType = "nydus" + case soci: + // Convert image to SOCI format + convertedRef, err := snapshotterutil.ConvertSociIndexV2(ctx, client, srcRef, targetRef, options.GOptions, options.SociOptions) + if err != nil { + return fmt.Errorf("failed to convert image to SOCI format: %w", err) + } + res := converterutil.ConvertedImageInfo{ + Image: convertedRef, + } + return printConvertedImage(options.Stdout, options, res) } if convertType != "overlaybd" { @@ -270,6 +286,13 @@ func getESGZConvertOpts(options types.ImageConvertOptions) ([]estargz.Option, er var ignored []string esgzOpts = append(esgzOpts, estargz.WithAllowPrioritizeNotFound(&ignored)) } + if options.EstargzGzipHelper != "" { + gzipHelperFunc, err := estargzdecompressutil.GetGzipHelperFunc(options.EstargzGzipHelper) + if err != nil { + return nil, err + } + esgzOpts = append(esgzOpts, estargz.WithGzipHelperFunc(gzipHelperFunc)) + } return esgzOpts, nil } diff --git a/pkg/cmd/image/import.go b/pkg/cmd/image/import.go new file mode 100644 index 00000000000..432d5665a90 --- /dev/null +++ b/pkg/cmd/image/import.go @@ -0,0 +1,352 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + pathpkg "path" + "time" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/leases" + "github.com/containerd/containerd/v2/core/transfer" + tarchive "github.com/containerd/containerd/v2/core/transfer/archive" + transferimage "github.com/containerd/containerd/v2/core/transfer/image" + "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/transferutil" +) + +func Import(ctx context.Context, client *containerd.Client, options types.ImageImportOptions) (string, error) { + prefix := options.Reference + if prefix == "" { + prefix = fmt.Sprintf("import-%s", time.Now().Format("2006-01-02")) + } + + parsed, err := referenceutil.Parse(prefix) + if err != nil { + return "", err + } + imageName := parsed.String() + + platUnpack := platforms.DefaultSpec() + var opts []transferimage.StoreOpt + if options.Platform != "" { + p, err := platforms.Parse(options.Platform) + if err != nil { + return "", err + } + platUnpack = p + opts = append(opts, transferimage.WithPlatforms(platUnpack)) + } + + opts = append(opts, transferimage.WithUnpack(platUnpack, options.GOptions.Snapshotter)) + opts = append(opts, transferimage.WithDigestRef(imageName, true, true)) + + var r io.ReadCloser + if rc, ok := options.Stdin.(io.ReadCloser); ok { + r = rc + } else { + r = io.NopCloser(options.Stdin) + } + + converted, cleanup, err := ensureOCIArchive(ctx, client, r, options, prefix) + if err != nil { + return "", err + } + defer cleanup() + + iis := tarchive.NewImageImportStream(converted, "") + is := transferimage.NewStore("", opts...) + + pf, done := transferutil.ProgressHandler(ctx, os.Stderr) + defer done() + + if err := client.Transfer(ctx, iis, is, transfer.WithProgress(pf)); err != nil { + return "", err + } + + return imageName, nil +} + +func ensureOCIArchive(ctx context.Context, client *containerd.Client, r io.ReadCloser, options types.ImageImportOptions, prefix string) (io.ReadCloser, func(), error) { + buf := &bytes.Buffer{} + tee := io.TeeReader(r, buf) + + isStandardArchive, err := detectStandardImageArchive(tee) + if err != nil { + return nil, func() {}, err + } + + combined := io.NopCloser(io.MultiReader(buf, r)) + if isStandardArchive { + return combined, func() { r.Close() }, nil + } + + converted, err := convertRootfsToOCIArchive(ctx, client, combined, options, prefix) + if err != nil { + r.Close() + return nil, func() {}, err + } + + cleanup := func() { + r.Close() + if converted != nil { + converted.Close() + } + } + + return converted, cleanup, nil +} + +func detectStandardImageArchive(r io.Reader) (bool, error) { + tr := tar.NewReader(r) + const maxHeadersToCheck = 10 + + for i := 0; i < maxHeadersToCheck; i++ { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return false, err + } + + name := pathpkg.Clean(hdr.Name) + if name == "manifest.json" || name == ocispec.ImageLayoutFile { + return true, nil + } + } + return false, nil +} + +func convertRootfsToOCIArchive(ctx context.Context, client *containerd.Client, r io.ReadCloser, options types.ImageImportOptions, prefix string) (io.ReadCloser, error) { + defer r.Close() + + ctx, done, err := client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour)) + if err != nil { + return nil, err + } + defer done(ctx) + + decomp, err := compression.DecompressStream(r) + if err != nil { + return nil, err + } + defer decomp.Close() + + cs := client.ContentStore() + ref := randomRef("import-layer-") + w, err := content.OpenWriter(ctx, cs, content.WithRef(ref)) + if err != nil { + return nil, err + } + defer w.Close() + + if err := w.Truncate(0); err != nil { + return nil, err + } + + layerDigest, diffID, layerSize, err := compressAndWriteLayer(ctx, w, decomp) + if err != nil { + return nil, err + } + + imgConfig, configDigest, err := buildImageConfig(diffID, options) + if err != nil { + return nil, err + } + + layerContent, err := readLayerContent(ctx, cs, layerDigest, layerSize) + if err != nil { + return nil, err + } + + return buildDockerArchive(imgConfig, configDigest, layerContent, layerDigest, prefix) +} + +func compressAndWriteLayer(ctx context.Context, w content.Writer, r io.Reader) (digest.Digest, digest.Digest, int64, error) { + digester := digest.Canonical.Digester() + tee := io.TeeReader(r, digester.Hash()) + pr, pw := io.Pipe() + gz := gzip.NewWriter(pw) + + doneCh := make(chan error, 1) + go func() { + defer func() { + _ = gz.Close() + }() + + if _, err := io.Copy(gz, tee); err != nil { + doneCh <- err + _ = pw.CloseWithError(err) + return + } + if err := gz.Close(); err != nil { + doneCh <- err + _ = pw.CloseWithError(err) + return + } + doneCh <- pw.Close() + }() + + n, err := io.Copy(w, pr) + if err != nil { + return "", "", 0, err + } + if err := <-doneCh; err != nil { + return "", "", 0, err + } + + diffID := digester.Digest() + labels := map[string]string{ + "containerd.io/uncompressed": diffID.String(), + } + if err := w.Commit(ctx, n, "", content.WithLabels(labels)); err != nil && !errdefs.IsAlreadyExists(err) { + return "", "", 0, err + } + + return w.Digest(), diffID, n, nil +} + +func buildImageConfig(diffID digest.Digest, options types.ImageImportOptions) ([]byte, digest.Digest, error) { + ociplat := platforms.DefaultSpec() + if options.Platform != "" { + if p, err := platforms.Parse(options.Platform); err == nil { + ociplat = p + } + } + + created := time.Now().UTC() + imgConfig := ocispec.Image{ + Platform: ocispec.Platform{ + Architecture: ociplat.Architecture, + OS: ociplat.OS, + OSVersion: ociplat.OSVersion, + Variant: ociplat.Variant, + }, + Created: &created, + Config: ocispec.ImageConfig{}, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{diffID}, + }, + History: []ocispec.History{{ + Created: &created, + Comment: options.Message, + }}, + } + + configJSON, err := json.Marshal(imgConfig) + if err != nil { + return nil, "", err + } + return configJSON, digest.FromBytes(configJSON), nil +} + +func readLayerContent(ctx context.Context, cs content.Store, layerDigest digest.Digest, size int64) ([]byte, error) { + ra, err := cs.ReaderAt(ctx, ocispec.Descriptor{Digest: layerDigest, Size: size}) + if err != nil { + return nil, err + } + defer ra.Close() + + layerContent := make([]byte, size) + if _, err := ra.ReadAt(layerContent, 0); err != nil { + return nil, err + } + return layerContent, nil +} + +func buildDockerArchive(configJSON []byte, configDigest digest.Digest, layerContent []byte, layerDigest digest.Digest, prefix string) (io.ReadCloser, error) { + layerFileName := layerDigest.Encoded() + ".tar.gz" + configFileName := configDigest.Encoded() + ".json" + + var repoTags []string + if parsed, err := referenceutil.Parse(prefix); err == nil && parsed.String() != "" { + repoTags = []string{parsed.String()} + } + + dockerManifest := []struct { + Config string `json:"Config"` + RepoTags []string `json:"RepoTags,omitempty"` + Layers []string `json:"Layers"` + }{{ + Config: configFileName, + RepoTags: repoTags, + Layers: []string{layerFileName}, + }} + + dockerManifestJSON, err := json.Marshal(dockerManifest) + if err != nil { + return nil, err + } + + buf := &bytes.Buffer{} + tw := tar.NewWriter(buf) + + files := []struct { + name string + content []byte + }{ + {"manifest.json", dockerManifestJSON}, + {configFileName, configJSON}, + {layerFileName, layerContent}, + } + + for _, f := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: f.name, + Mode: 0644, + Size: int64(len(f.content)), + }); err != nil { + return nil, err + } + if _, err := tw.Write(f.content); err != nil { + return nil, err + } + } + + if err := tw.Close(); err != nil { + return nil, err + } + + return io.NopCloser(buf), nil +} + +func randomRef(prefix string) string { + var b [6]byte + _, _ = rand.Read(b[:]) + return prefix + base64.RawURLEncoding.EncodeToString(b[:]) +} diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index c7440b459fb..0476b6b55fb 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -312,6 +312,10 @@ func readIndex(ctx context.Context, provider content.Provider, snapshotter snaps // Iterate over manifest descriptors and read them all for _, manifestDescriptor := range index.Manifests { + if isAttestationManifestDescriptor(manifestDescriptor) { + continue + } + manifest, err := readManifest(ctx, provider, snapshotter, manifestDescriptor) if err != nil { continue @@ -419,3 +423,9 @@ func (x *imagePrinter) printImageSinglePlatform(desc ocispec.Descriptor, img ima } return nil } + +func isAttestationManifestDescriptor(desc ocispec.Descriptor) bool { + const manifestReferenceType = "vnd.docker.reference.type" + const attestationManifest = "attestation-manifest" + return desc.Annotations[manifestReferenceType] == attestationManifest +} diff --git a/pkg/cmd/image/pull.go b/pkg/cmd/image/pull.go index 1d943c9b62d..848f7179300 100644 --- a/pkg/cmd/image/pull.go +++ b/pkg/cmd/image/pull.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/ipfs" "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/signutil" @@ -62,7 +63,7 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, return nil, err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "api"), []byte(options.IPFSAddress), 0600); err != nil { + if err := filesystem.WriteFile(filepath.Join(dir, "api"), []byte(options.IPFSAddress), 0600); err != nil { return nil, err } ipfsPath = dir diff --git a/pkg/cmd/image/push.go b/pkg/cmd/image/push.go index 0c463e76f02..505e8eb7129 100644 --- a/pkg/cmd/image/push.go +++ b/pkg/cmd/image/push.go @@ -37,15 +37,19 @@ import ( dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" "github.com/containerd/containerd/v2/pkg/reference" "github.com/containerd/log" + "github.com/containerd/platforms" "github.com/containerd/stargz-snapshotter/estargz" "github.com/containerd/stargz-snapshotter/estargz/zstdchunked" estargzconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil" nerdconverter "github.com/containerd/nerdctl/v2/pkg/imgutil/converter" "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" "github.com/containerd/nerdctl/v2/pkg/imgutil/push" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/ipfs" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" @@ -85,7 +89,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "api"), []byte(options.IpfsAddress), 0600); err != nil { + if err := filesystem.WriteFile(filepath.Join(dir, "api"), []byte(options.IpfsAddress), 0600); err != nil { return err } ipfsPath = dir @@ -109,7 +113,6 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return err } ref := parsedReference.String() - refDomain := parsedReference.Domain platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platforms) if err != nil { @@ -145,53 +148,27 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options defer client.ImageService().Delete(ctx, esgzImg.Name, images.SynchronousDelete()) log.G(ctx).Infof("pushing as an eStargz image (%s, %s)", esgzImg.Target.MediaType, esgzImg.Target.Digest) } - - // In order to push images where most layers are the same but the - // repository name is different, it is necessary to refresh the - // PushTracker. Otherwise, the MANIFEST_BLOB_UNKNOWN error will occur due - // to the registry not creating the corresponding layer link file, - // resulting in the failure of the entire image push. - pushTracker := docker.NewInMemoryTracker() - - pushFunc := func(r remotes.Resolver) error { - return push.Push(ctx, client, r, pushTracker, options.Stdout, pushRef, ref, platMC, options.AllowNondistributableArtifacts, options.Quiet) - } - - var dOpts []dockerconfigresolver.Opt - if options.GOptions.InsecureRegistry { - log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) - } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) - - ho, err := dockerconfigresolver.NewHostOptions(ctx, refDomain, dOpts...) - if err != nil { - return err - } - - resolverOpts := docker.ResolverOptions{ - Tracker: pushTracker, - Hosts: dockerconfig.ConfigureHosts(ctx, *ho), - } - - resolver := docker.NewResolver(resolverOpts) - if err = pushFunc(resolver); err != nil { - // In some circumstance (e.g. people just use 80 port to support pure http), the error will contain message like "dial tcp : connection refused" - if !errors.Is(err, http.ErrSchemeMismatch) && !errutil.IsErrConnectionRefused(err) { + if !options.AllowNondistributableArtifacts { + if err := pushImageWithLocal(ctx, client, parsedReference, pushRef, ref, options, platMC); err != nil { return err } - if options.GOptions.InsecureRegistry { - log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) - resolver, err = dockerconfigresolver.New(ctx, refDomain, dOpts...) - if err != nil { + } else { + // Transfer service is available in containerd 1.7, but full support is only in 2.0+ + // For containerd 1.7, use the legacy resolver-based push method for better compatibility + useTransferAPI := containerdutil.SupportsFullTransferService(ctx, client) + if !useTransferAPI { + log.G(ctx).Debug("Detected containerd < 2.0, using legacy push method") + } + + if useTransferAPI { + if err := imgutil.PushImageWithTransfer(ctx, client, parsedReference, pushRef, ref, options); err != nil { + return err + } + } else { + if err := pushImageWithLocal(ctx, client, parsedReference, pushRef, ref, options, platMC); err != nil { return err } - return pushFunc(resolver) } - log.G(ctx).WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) - log.G(ctx).Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") - return err } img, err := client.ImageService().Get(ctx, pushRef) @@ -209,7 +186,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return err } if options.GOptions.Snapshotter == "soci" { - if err = snapshotterutil.CreateSoci(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil { + if err = snapshotterutil.CreateSociIndexV1(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil { return err } if err = snapshotterutil.PushSoci(ref, options.GOptions, options.AllPlatforms, options.Platforms); err != nil { @@ -262,3 +239,57 @@ func isReusableESGZ(ctx context.Context, cs content.Store, desc ocispec.Descript } return true } + +func pushImageWithLocal(ctx context.Context, client *containerd.Client, parsedReference *referenceutil.ImageReference, pushRef, rawRef string, options types.ImagePushOptions, platMC platforms.MatchComparer) error { + ref := parsedReference.String() + refDomain := parsedReference.Domain + + // In order to push images where most layers are the same but the + // repository name is different, it is necessary to refresh the + // PushTracker. Otherwise, the MANIFEST_BLOB_UNKNOWN error will occur due + // to the registry not creating the corresponding layer link file, + // resulting in the failure of the entire image push. + pushTracker := docker.NewInMemoryTracker() + + pushFunc := func(r remotes.Resolver) error { + return push.Push(ctx, client, r, pushTracker, options.Stdout, pushRef, ref, platMC, options.AllowNondistributableArtifacts, options.Quiet) + } + + var dOpts []dockerconfigresolver.Opt + if options.GOptions.InsecureRegistry { + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", refDomain) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) + + ho, err := dockerconfigresolver.NewHostOptions(ctx, refDomain, dOpts...) + if err != nil { + return err + } + + resolverOpts := docker.ResolverOptions{ + Tracker: pushTracker, + Hosts: dockerconfig.ConfigureHosts(ctx, *ho), + } + + resolver := docker.NewResolver(resolverOpts) + if err = pushFunc(resolver); err != nil { + // In some circumstance (e.g. people just use 80 port to support pure http), the error will contain message like "dial tcp : connection refused" + if !errors.Is(err, http.ErrSchemeMismatch) && !errutil.IsErrConnectionRefused(err) { + return err + } + if options.GOptions.InsecureRegistry { + log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) + dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) + resolver, err = dockerconfigresolver.New(ctx, refDomain, dOpts...) + if err != nil { + return err + } + return pushFunc(resolver) + } + log.G(ctx).WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) + log.G(ctx).Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + return err + } + return nil +} diff --git a/pkg/cmd/image/save.go b/pkg/cmd/image/save.go index 0a499b3f135..a35a83a97f5 100644 --- a/pkg/cmd/image/save.go +++ b/pkg/cmd/image/save.go @@ -19,55 +19,104 @@ package image import ( "context" "fmt" + "io" + "os" + + "github.com/distribution/reference" + "github.com/opencontainers/go-digest" containerd "github.com/containerd/containerd/v2/client" - "github.com/containerd/containerd/v2/core/images/archive" + "github.com/containerd/containerd/v2/core/transfer" + tarchive "github.com/containerd/containerd/v2/core/transfer/archive" + transferimage "github.com/containerd/containerd/v2/core/transfer/image" + "github.com/containerd/platforms" "github.com/containerd/nerdctl/v2/pkg/api/types" - "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/transferutil" ) // Save exports `images` to a `io.Writer` (e.g., a file writer, or os.Stdout) specified by `options.Stdout`. -func Save(ctx context.Context, client *containerd.Client, images []string, options types.ImageSaveOptions, exportOpts ...archive.ExportOpt) error { +func Save(ctx context.Context, client *containerd.Client, images []string, options types.ImageSaveOptions) error { images = strutil.DedupeStrSlice(images) + var exportOpts []tarchive.ExportOpt + + if len(options.Platform) > 0 { + for _, ps := range options.Platform { + p, err := platforms.Parse(ps) + if err != nil { + return fmt.Errorf("invalid platform %q: %w", ps, err) + } + exportOpts = append(exportOpts, tarchive.WithPlatform(p)) + } + } + if options.AllPlatforms { + exportOpts = append(exportOpts, tarchive.WithAllPlatforms) + } + platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) if err != nil { return err } - exportOpts = append(exportOpts, archive.WithPlatform(platMC)) - imageStore := client.ImageService() + imageService := client.ImageService() + var storeOpts []transferimage.StoreOpt + for _, img := range images { + var imageRef string - savedImages := make(map[string]struct{}) - walker := &imagewalker.ImageWalker{ - Client: client, - OnFound: func(ctx context.Context, found imagewalker.Found) error { - if found.UniqueImages > 1 { - return fmt.Errorf("ambiguous digest ID: multiple IDs found with provided prefix %s", found.Req) + var dgst digest.Digest + var err error + if dgst, err = digest.Parse(img); err != nil { + if dgst, err = digest.Parse("sha256:" + img); err != nil { + named, err := reference.ParseNormalizedNamed(img) + if err != nil { + return fmt.Errorf("invalid image name %q: %w", img, err) + } + imageRef = reference.TagNameOnly(named).String() + err = EnsureAllContent(ctx, client, imageRef, platMC, options.GOptions) + if err != nil { + return err + } + storeOpts = append(storeOpts, transferimage.WithExtraReference(imageRef)) + continue } + } - // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 - err = EnsureAllContent(ctx, client, found.Image.Name, platMC, options.GOptions) - if err != nil { - return err - } + filters := []string{fmt.Sprintf("target.digest~=^%s$", dgst.String())} + imageList, err := imageService.List(ctx, filters...) + if err != nil { + return fmt.Errorf("failed to list images: %w", err) + } + if len(imageList) == 0 { + return fmt.Errorf("image %q: not found", img) + } - imgName := found.Image.Name - if _, ok := savedImages[imgName]; !ok { - savedImages[imgName] = struct{}{} - exportOpts = append(exportOpts, archive.WithImage(imageStore, imgName)) - } - return nil - }, + imageRef = imageList[0].Name + err = EnsureAllContent(ctx, client, imageRef, platMC, options.GOptions) + if err != nil { + return err + } + storeOpts = append(storeOpts, transferimage.WithExtraReference(imageRef)) } - // check if all images exist - if err := walker.WalkAll(ctx, images, false); err != nil { - return err - } + w := nopWriteCloser{options.Stdout} + + pf, done := transferutil.ProgressHandler(ctx, os.Stderr) + defer done() + + return client.Transfer(ctx, + transferimage.NewStore("", storeOpts...), + tarchive.NewImageExportStream(w, "", exportOpts...), + transfer.WithProgress(pf), + ) +} + +type nopWriteCloser struct { + io.Writer +} - return client.Export(ctx, options.Stdout, exportOpts...) +func (nopWriteCloser) Close() error { + return nil } diff --git a/pkg/cmd/image/tag.go b/pkg/cmd/image/tag.go index 60ab191d4f7..e9476c2bde8 100644 --- a/pkg/cmd/image/tag.go +++ b/pkg/cmd/image/tag.go @@ -18,79 +18,37 @@ package image import ( "context" - "fmt" containerd "github.com/containerd/containerd/v2/client" - "github.com/containerd/containerd/v2/core/images" - "github.com/containerd/errdefs" - "github.com/containerd/log" + transferimage "github.com/containerd/containerd/v2/core/transfer/image" "github.com/containerd/nerdctl/v2/pkg/api/types" - "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) func Tag(ctx context.Context, client *containerd.Client, options types.ImageTagOptions) error { - imageService := client.ImageService() - var srcName string - walker := &imagewalker.ImageWalker{ - Client: client, - OnFound: func(ctx context.Context, found imagewalker.Found) error { - if srcName == "" { - srcName = found.Image.Name - } - return nil - }, - } - matchCount, err := walker.Walk(ctx, options.Source) + parsedSource, err := referenceutil.Parse(options.Source) if err != nil { return err } - if matchCount < 1 { - return fmt.Errorf("%s: not found", options.Source) - } - parsedReference, err := referenceutil.Parse(options.Target) + parsedTarget, err := referenceutil.Parse(options.Target) if err != nil { return err } - ctx, done, err := client.WithLease(ctx) + platMC, err := platformutil.NewMatchComparer(false, nil) if err != nil { return err } - defer done(ctx) - - // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 - platMC, err := platformutil.NewMatchComparer(true, nil) + err = EnsureAllContent(ctx, client, parsedSource.String(), platMC, options.GOptions) if err != nil { return err } - err = EnsureAllContent(ctx, client, srcName, platMC, options.GOptions) - if err != nil { - log.G(ctx).Warn("Unable to fetch missing layers before committing. " + - "If you try to save or push this image, it might fail. See https://github.com/containerd/nerdctl/issues/3439.") - } - - img, err := imageService.Get(ctx, srcName) - if err != nil { - return err - } + sourceStore := transferimage.NewStore(parsedSource.String()) + targetStore := transferimage.NewStore(parsedTarget.String()) - img.Name = parsedReference.String() - if _, err = imageService.Create(ctx, img); err != nil { - if errdefs.IsAlreadyExists(err) { - if err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()); err != nil { - return err - } - if _, err = imageService.Create(ctx, img); err != nil { - return err - } - } else { - return err - } - } - return nil + return client.Transfer(ctx, sourceStore, targetStore) } diff --git a/pkg/cmd/ipfs/registry_serve.go b/pkg/cmd/ipfs/registry_serve.go index 09294032c1d..47cd3fc985f 100644 --- a/pkg/cmd/ipfs/registry_serve.go +++ b/pkg/cmd/ipfs/registry_serve.go @@ -24,6 +24,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/ipfs" ) @@ -35,7 +36,7 @@ func RegistryServe(options types.IPFSRegistryServeOptions) error { return err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "api"), []byte(options.IPFSAddress), 0600); err != nil { + if err := filesystem.WriteFile(filepath.Join(dir, "api"), []byte(options.IPFSAddress), 0600); err != nil { return err } ipfsPath = dir diff --git a/pkg/cmd/manifest/annotate.go b/pkg/cmd/manifest/annotate.go new file mode 100644 index 00000000000..66e74f693bb --- /dev/null +++ b/pkg/cmd/manifest/annotate.go @@ -0,0 +1,90 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "errors" + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifeststore" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/store" +) + +func Annotate(ctx context.Context, listRef string, manifestRef string, options types.ManifestAnnotateOptions) error { + parsedListRef, err := referenceutil.Parse(listRef) + if err != nil { + return fmt.Errorf("failed to parse list reference: %w", err) + } + + parsedManifestRef, err := referenceutil.Parse(manifestRef) + if err != nil { + return fmt.Errorf("failed to parse manifest reference: %w", err) + } + + manifestStore, err := manifeststore.NewStore(options.GOptions.DataRoot) + if err != nil { + return fmt.Errorf("failed to create manifest store: %w", err) + } + + imageManifest, err := manifestStore.Get(parsedListRef, parsedManifestRef) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("manifest for image %s does not exist in %s", manifestRef, listRef) + } + return fmt.Errorf("failed to get manifest: %w", err) + } + + if imageManifest.Descriptor.Platform == nil { + imageManifest.Descriptor.Platform = new(ocispec.Platform) + } + + if options.Os != "" { + imageManifest.Descriptor.Platform.OS = options.Os + } + + if options.Arch != "" { + imageManifest.Descriptor.Platform.Architecture = options.Arch + } + + if options.Variant != "" { + imageManifest.Descriptor.Platform.Variant = options.Variant + } + + if options.OsVersion != "" { + imageManifest.Descriptor.Platform.OSVersion = options.OsVersion + } + + for _, osFeature := range options.OsFeatures { + imageManifest.Descriptor.Platform.OSFeatures = appendIfUnique(imageManifest.Descriptor.Platform.OSFeatures, osFeature) + } + + return manifestStore.Save(parsedListRef, parsedManifestRef, imageManifest) +} + +func appendIfUnique(list []string, str string) []string { + for _, s := range list { + if s == str { + return list + } + } + return append(list, str) +} diff --git a/pkg/cmd/manifest/create.go b/pkg/cmd/manifest/create.go new file mode 100644 index 00000000000..58774631b7a --- /dev/null +++ b/pkg/cmd/manifest/create.go @@ -0,0 +1,86 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifeststore" + "github.com/containerd/nerdctl/v2/pkg/manifestutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +// Create creates a local manifest list/index +func Create(ctx context.Context, listRef string, manifestRefs []string, options types.ManifestCreateOptions) (string, error) { + parsedListRef, err := referenceutil.Parse(listRef) + if err != nil { + return "", fmt.Errorf("failed to parse list reference: %w", err) + } + + manifestStore, err := manifeststore.NewStore(options.GOptions.DataRoot) + if err != nil { + return "", fmt.Errorf("failed to create manifest store: %w", err) + } + + existingManifests, err := manifestStore.GetList(parsedListRef) + if err == nil && len(existingManifests) > 0 && !options.Amend { + return "", fmt.Errorf("refusing to amend an existing manifest list with no --amend flag") + } + + for _, manifestRef := range manifestRefs { + parsedRef, err := referenceutil.Parse(manifestRef) + if err != nil { + return "", fmt.Errorf("failed to parse manifest reference %s: %w", manifestRef, err) + } + + manifest, desc, rawData, err := manifestutil.GetManifest(ctx, parsedRef, options.GOptions, options.Insecure) + if err != nil { + return "", fmt.Errorf("failed to fetch manifest %s: %w", manifestRef, err) + } + + // Check if the manifest is manifest list + if desc.MediaType == images.MediaTypeDockerSchema2ManifestList || desc.MediaType == ocispec.MediaTypeImageIndex { + return "", fmt.Errorf("%s is a manifest list", manifestRef) + } + + imageManifest, err := manifestutil.CreateManifestEntry(parsedRef, desc, rawData) + if err != nil { + return "", fmt.Errorf("failed to create manifest entry for %s: %w", manifestRef, err) + } + + // Get platform information from config + if desc.MediaType == ocispec.MediaTypeImageManifest || desc.MediaType == images.MediaTypeDockerSchema2Manifest { + platform, err := manifestutil.GetPlatform(ctx, parsedRef.Domain, options.GOptions, options.Insecure, manifestRef, manifest) + if err != nil { + return "", fmt.Errorf("failed to extract platform for %s: %w", manifestRef, err) + } + imageManifest.Descriptor.Platform = platform + } + + if err := manifestStore.Save(parsedListRef, parsedRef, &imageManifest); err != nil { + return "", fmt.Errorf("failed to store manifest %s: %w", manifestRef, err) + } + } + + return parsedListRef.String(), nil +} diff --git a/pkg/cmd/manifest/inspect.go b/pkg/cmd/manifest/inspect.go new file mode 100644 index 00000000000..10e59dc72f0 --- /dev/null +++ b/pkg/cmd/manifest/inspect.go @@ -0,0 +1,114 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/manifestutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +func Inspect(ctx context.Context, rawRef string, options types.ManifestInspectOptions) ([]interface{}, error) { + parsedRef, err := referenceutil.Parse(rawRef) + if err != nil { + return nil, fmt.Errorf("failed to parse reference: %w", err) + } + + manifest, desc, rawData, err := manifestutil.GetManifest(ctx, parsedRef, options.GOptions, options.Insecure) + if err != nil { + return nil, err + } + + if options.Verbose { + return formatVerboseOutput(ctx, parsedRef, manifest, desc, rawData, options.Insecure) + } + + // Return manifest wrapped in array for formatting compatibility + return []interface{}{manifest}, nil +} + +// formatVerboseOutput formats manifest data in Docker-compatible verbose format +func formatVerboseOutput(ctx context.Context, parsedRef *referenceutil.ImageReference, manifest interface{}, desc ocispec.Descriptor, rawData []byte, insecure bool) ([]interface{}, error) { + switch desc.MediaType { + case ocispec.MediaTypeImageIndex: + index, ok := manifest.(manifesttypes.OCIIndexStruct) + if !ok { + return nil, fmt.Errorf("expected ocispec.Index for OCI index") + } + return verboseEntriesForManifests(ctx, parsedRef, index.Manifests, insecure) + + case images.MediaTypeDockerSchema2ManifestList: + di, ok := manifest.(manifesttypes.DockerManifestListStruct) + if !ok { + return nil, fmt.Errorf("expected DockerManifestListStruct for Docker manifest list") + } + return verboseEntriesForManifests(ctx, parsedRef, di.Manifests, insecure) + + default: + entry, err := manifestutil.CreateManifestEntry(parsedRef, desc, rawData) + if err != nil { + return nil, err + } + return []interface{}{entry}, nil + } +} + +// verboseEntriesForManifests fetches and formats verbose entries for a list of descriptors +func verboseEntriesForManifests(ctx context.Context, parsedRef *referenceutil.ImageReference, manifests []ocispec.Descriptor, insecure bool) ([]interface{}, error) { + + resolver, err := manifestutil.CreateResolver(ctx, parsedRef.Domain, types.GlobalCommandOptions{}, insecure) + if err != nil { + return nil, fmt.Errorf("failed to create resolver: %w", err) + } + + fetcher, err := resolver.Fetcher(ctx, parsedRef.String()) + if err != nil { + return nil, fmt.Errorf("failed to create fetcher: %w", err) + } + + entries := make([]interface{}, 0, len(manifests)) + + for _, mdesc := range manifests { + rc, err := fetcher.Fetch(ctx, mdesc) + if err != nil { + return nil, err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + entry, err := manifestutil.CreateManifestEntry(parsedRef, mdesc, data) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + return entries, nil +} diff --git a/pkg/cmd/manifest/push.go b/pkg/cmd/manifest/push.go new file mode 100644 index 00000000000..02a6b7181a2 --- /dev/null +++ b/pkg/cmd/manifest/push.go @@ -0,0 +1,233 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifeststore" + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/manifestutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +func Push(ctx context.Context, listRef string, options types.ManifestPushOptions) error { + parsedTargetRef, err := referenceutil.Parse(listRef) + if err != nil { + return fmt.Errorf("failed to parse target reference %s: %w", listRef, err) + } + + manifestStore, err := manifeststore.NewStore(options.GOptions.DataRoot) + if err != nil { + return fmt.Errorf("failed to create manifest store: %w", err) + } + + manifests, err := manifestStore.GetList(parsedTargetRef) + if err != nil { + return fmt.Errorf("failed to get manifests: %w", err) + } + + if len(manifests) == 0 { + return fmt.Errorf("no manifests found for %s", listRef) + } + + resolver, err := manifestutil.CreateResolver(ctx, parsedTargetRef.Domain, options.GOptions, options.Insecure) + if err != nil { + return fmt.Errorf("failed to create resolver: %w", err) + } + + if err := pushIndividualManifests(ctx, resolver, manifests, parsedTargetRef, options); err != nil { + return fmt.Errorf("failed to push individual manifests: %w", err) + } + + manifestList, err := buildManifestList(manifests) + if err != nil { + return fmt.Errorf("failed to build manifest list: %w", err) + } + + digest, err := pushManifestList(ctx, resolver, parsedTargetRef, manifestList) + if err != nil { + return fmt.Errorf("failed to push manifest list: %w", err) + } + + fmt.Fprintln(options.Stdout, digest) + + if options.Purge { + if err := manifestStore.Remove(parsedTargetRef); err != nil { + return fmt.Errorf("failed to remove manifest list from store: %w", err) + } + } + + return nil +} + +func buildManifestList(manifests []*manifesttypes.DockerManifestEntry) (manifesttypes.DockerManifestList, error) { + if len(manifests) == 0 { + return manifesttypes.DockerManifestList{}, fmt.Errorf("no manifests to build list from") + } + + var descriptors []manifesttypes.DockerManifestDescriptor + useOCIIndex := false + + for _, manifest := range manifests { + if manifest.Descriptor.Platform == nil || + manifest.Descriptor.Platform.Architecture == "" || + manifest.Descriptor.Platform.OS == "" { + return manifesttypes.DockerManifestList{}, fmt.Errorf("manifest %s must have an OS and Architecture to be pushed to a registry", manifest.Ref) + } + + if manifest.Descriptor.MediaType == ocispec.MediaTypeImageManifest { + useOCIIndex = true + } + + descriptors = append(descriptors, manifesttypes.DockerManifestDescriptor{ + MediaType: manifest.Descriptor.MediaType, + Size: manifest.Descriptor.Size, + Digest: manifest.Descriptor.Digest, + Platform: *manifest.Descriptor.Platform, + }) + } + manifestList := manifesttypes.DockerManifestList{ + SchemaVersion: 2, + MediaType: images.MediaTypeDockerSchema2ManifestList, + Manifests: descriptors, + } + if useOCIIndex { + manifestList.MediaType = ocispec.MediaTypeImageIndex + } + + return manifestList, nil +} + +func pushIndividualManifests(ctx context.Context, resolver remotes.Resolver, manifests []*manifesttypes.DockerManifestEntry, targetRef *referenceutil.ImageReference, options types.ManifestPushOptions) error { + targetDomain := targetRef.Domain + targetRepo := targetRef.Path + + for _, manifest := range manifests { + manifestRef, err := referenceutil.Parse(manifest.Ref) + if err != nil { + return fmt.Errorf("failed to parse manifest reference %s: %w", manifest.Ref, err) + } + + if manifestRef.Domain != targetDomain { + return fmt.Errorf("cannot use source images from a different registry than the target image: %s != %s", manifestRef.Domain, targetDomain) + } + + var targetManifestRef string + if manifestRef.Domain != targetDomain { + targetManifestRef = fmt.Sprintf("%s/%s@%s", targetDomain, manifestRef.Path, manifest.Descriptor.Digest) + } else { + targetManifestRef = fmt.Sprintf("%s/%s@%s", targetDomain, targetRepo, manifest.Descriptor.Digest) + } + + if err := pushManifest(ctx, resolver, targetManifestRef, manifest); err != nil { + return fmt.Errorf("failed to push manifest %s: %w", targetManifestRef, err) + } + + fmt.Fprintf(options.Stdout, "Pushed ref %s with digest: %s\n", targetManifestRef, manifest.Descriptor.Digest) + } + + return nil +} + +func pushManifest(ctx context.Context, resolver remotes.Resolver, ref string, manifest *manifesttypes.DockerManifestEntry) error { + rawData, err := base64.StdEncoding.DecodeString(manifest.Raw) + if err != nil { + return fmt.Errorf("failed to decode manifest data: %w", err) + } + + pusher, err := resolver.Pusher(ctx, ref) + if err != nil { + return fmt.Errorf("failed to create pusher: %w", err) + } + + writer, err := pusher.Push(ctx, manifest.Descriptor) + if err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return nil + } + return fmt.Errorf("failed to create content writer: %w", err) + } + defer writer.Close() + + if _, err := writer.Write(rawData); err != nil { + return fmt.Errorf("failed to write manifest data: %w", err) + } + + if err := writer.Commit(ctx, manifest.Descriptor.Size, manifest.Descriptor.Digest); err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return nil + } + return fmt.Errorf("failed to commit manifest: %w", err) + } + + return nil +} + +func pushManifestList(ctx context.Context, resolver remotes.Resolver, targetRef *referenceutil.ImageReference, manifestList manifesttypes.DockerManifestList) (digest.Digest, error) { + data, err := json.MarshalIndent(manifestList, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal manifest list: %w", err) + } + + dgst := digest.FromBytes(data) + + desc := ocispec.Descriptor{ + MediaType: manifestList.MediaType, + Size: int64(len(data)), + Digest: dgst, + } + + pusher, err := resolver.Pusher(ctx, targetRef.String()) + if err != nil { + return "", fmt.Errorf("failed to create pusher: %w", err) + } + + writer, err := pusher.Push(ctx, desc) + if err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return dgst, nil + } + return "", fmt.Errorf("failed to create content writer: %w", err) + } + defer writer.Close() + + if _, err := writer.Write(data); err != nil { + return "", fmt.Errorf("failed to write manifest list data: %w", err) + } + + if err := writer.Commit(ctx, desc.Size, desc.Digest); err != nil { + if errdefs.IsAlreadyExists(err) || strings.Contains(err.Error(), "already exists") { + return dgst, nil + } + return "", fmt.Errorf("failed to commit manifest list: %w", err) + } + + return dgst, nil +} diff --git a/pkg/cmd/manifest/rm.go b/pkg/cmd/manifest/rm.go new file mode 100644 index 00000000000..191e56dd4ff --- /dev/null +++ b/pkg/cmd/manifest/rm.go @@ -0,0 +1,51 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifest + +import ( + "context" + "fmt" + "strings" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/manifeststore" + "github.com/containerd/nerdctl/v2/pkg/manifestutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +func Remove(ctx context.Context, ref string, options types.GlobalCommandOptions) error { + parsedRef, err := referenceutil.Parse(ref) + if err != nil { + return fmt.Errorf("failed to parse reference: %w", err) + } + manifestStore, err := manifeststore.NewStore(options.DataRoot) + if err != nil { + return fmt.Errorf("failed to create manifest store: %w", err) + } + _, err = manifestStore.GetList(parsedRef) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return manifestutil.NewNoSuchManifestError(parsedRef.String()) + } + return err + } + err = manifestStore.Remove(parsedRef) + if err != nil { + return fmt.Errorf("failed to remove manifest list: %w", err) + } + return nil +} diff --git a/pkg/cmd/namespace/common.go b/pkg/cmd/namespace/common.go index e08939e0427..309d2f90f90 100644 --- a/pkg/cmd/namespace/common.go +++ b/pkg/cmd/namespace/common.go @@ -16,7 +16,16 @@ package namespace -import "strings" +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/compose-spec/compose-go/v2/errdefs" + + "github.com/containerd/containerd/v2/pkg/namespaces" +) func objectWithLabelArgs(args []string) map[string]string { if len(args) >= 1 { @@ -39,3 +48,16 @@ func labelArgs(labelStrings []string) map[string]string { return labels } + +// namespaceExists checks if the namespace exists +func namespaceExists(ctx context.Context, store namespaces.Store, namespace string) error { + nsList, err := store.List(ctx) + if err != nil { + return err + } + if slices.Contains(nsList, namespace) { + return nil + } + + return fmt.Errorf("namespace %s: %w", namespace, errdefs.ErrNotFound) +} diff --git a/pkg/cmd/namespace/inspect.go b/pkg/cmd/namespace/inspect.go index 3a7a4932815..ebe327da3d0 100644 --- a/pkg/cmd/namespace/inspect.go +++ b/pkg/cmd/namespace/inspect.go @@ -21,6 +21,7 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/formatter" @@ -28,10 +29,17 @@ import ( ) func Inspect(ctx context.Context, client *containerd.Client, inspectedNamespaces []string, options types.NamespaceInspectOptions) error { - result := make([]interface{}, len(inspectedNamespaces)) - for index, ns := range inspectedNamespaces { + result := []interface{}{} + warns := []error{} + + for _, ns := range inspectedNamespaces { ctx = namespaces.WithNamespace(ctx, ns) - labels, err := client.NamespaceService().Labels(ctx, ns) + namespaceService := client.NamespaceService() + if err := namespaceExists(ctx, namespaceService, ns); err != nil { + warns = append(warns, err) + continue + } + labels, err := namespaceService.Labels(ctx, ns) if err != nil { return err } @@ -39,7 +47,13 @@ func Inspect(ctx context.Context, client *containerd.Client, inspectedNamespaces Name: ns, Labels: &labels, } - result[index] = nsInspect + result = append(result, nsInspect) + } + if err := formatter.FormatSlice(options.Format, options.Stdout, result); err != nil { + return err + } + for _, warn := range warns { + log.G(ctx).Warn(warn) } - return formatter.FormatSlice(options.Format, options.Stdout, result) + return nil } diff --git a/pkg/cmd/namespace/list.go b/pkg/cmd/namespace/list.go new file mode 100644 index 00000000000..c01fb04c058 --- /dev/null +++ b/pkg/cmd/namespace/list.go @@ -0,0 +1,153 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package namespace + +import ( + "bytes" + "context" + "errors" + "fmt" + "sort" + "strings" + "text/tabwriter" + "text/template" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" +) + +func List(ctx context.Context, client *containerd.Client, options types.NamespaceListOptions) error { + nsStore := client.NamespaceService() + nsList, err := nsStore.List(ctx) + if err != nil { + return err + } + + dataStore, err := clientutil.DataStore(options.GOptions.DataRoot, options.GOptions.Address) + if err != nil { + return err + } + + w := options.Stdout + var tmpl *template.Template + namespaceList := []namespace{} + for _, ns := range nsList { + ctx = namespaces.WithNamespace(ctx, ns) + var numContainers, numImages, numVolumes int + + containers, err := client.Containers(ctx) + if err != nil { + log.L.Warn(err) + } + numContainers = len(containers) + + images, err := client.ImageService().List(ctx) + if err != nil { + log.L.Warn(err) + } + numImages = len(images) + + volStore, err := volumestore.New(dataStore, ns) + if err != nil { + log.L.Warn(err) + } else { + numVolumes, err = volStore.Count() + if err != nil { + log.L.Warn(err) + } + } + + labels, err := client.NamespaceService().Labels(ctx, ns) + if err != nil { + return err + } + namespaceList = append(namespaceList, namespace{ + Name: ns, + Containers: numContainers, + Images: numImages, + Volumes: numVolumes, + Labels: labels, + }) + } + + switch options.Format { + case "", "table", "wide": + if !options.Quiet { + w = tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) + // no "NETWORKS", because networks are global objects + fmt.Fprintln(w, "NAME\tCONTAINERS\tIMAGES\tVOLUMES\tLABELS") + } + case "raw": + return errors.New("unsupported format: \"raw\"") + default: + if options.Quiet { + return errors.New("format and quiet must not be specified together") + } + var err error + tmpl, err = formatter.ParseTemplate(options.Format) + if err != nil { + return err + } + } + + for _, namespace := range namespaceList { + if tmpl != nil { + var b bytes.Buffer + if err := tmpl.Execute(&b, namespace); err != nil { + return err + } + if _, err := fmt.Fprintln(w, b.String()); err != nil { + return err + } + } else if options.Quiet { + if _, err := fmt.Fprintln(w, namespace.Name); err != nil { + return err + } + } else { + format := "%s\t%d\t%d\t%d\t%v\t\n" + var labelStrings []string + for k, v := range namespace.Labels { + labelStrings = append(labelStrings, strings.Join([]string{k, v}, "=")) + } + sort.Strings(labelStrings) + args := []interface{}{} + args = append(args, namespace.Name, namespace.Containers, namespace.Images, namespace.Volumes, strings.Join(labelStrings, ",")) + if _, err := fmt.Fprintf(w, format, args...); err != nil { + return err + } + } + } + + if f, ok := w.(formatter.Flusher); ok { + return f.Flush() + } + return nil +} + +type namespace struct { + Name string + Containers int + Images int + Volumes int + Labels map[string]string +} diff --git a/pkg/cmd/namespace/update.go b/pkg/cmd/namespace/update.go index 63d2d8a5971..91b6b714905 100644 --- a/pkg/cmd/namespace/update.go +++ b/pkg/cmd/namespace/update.go @@ -27,6 +27,9 @@ import ( func Update(ctx context.Context, client *containerd.Client, namespace string, options types.NamespaceUpdateOptions) error { labelsArg := objectWithLabelArgs(options.Labels) namespaces := client.NamespaceService() + if err := namespaceExists(ctx, namespaces, namespace); err != nil { + return err + } for k, v := range labelsArg { if err := namespaces.SetLabel(ctx, namespace, k, v); err != nil { return err diff --git a/pkg/cmd/network/inspect.go b/pkg/cmd/network/inspect.go index 0a9090b95aa..6bacb75e445 100644 --- a/pkg/cmd/network/inspect.go +++ b/pkg/cmd/network/inspect.go @@ -58,16 +58,14 @@ func Inspect(ctx context.Context, client *containerd.Client, options types.Netwo } network := netList[0] - var filters = []string{fmt.Sprintf("labels.%q==%q", labels.Networks, []string{network.Name})} + var filters = []string{fmt.Sprintf(`labels.%q~="\\\"%s\\\""`, labels.Networks, network.Name)} filteredContainers, err := client.Containers(ctx, filters...) - if err != nil { return err } var containers []*native.Container - for _, container := range filteredContainers { nativeContainer, err := containerinspector.Inspect(ctx, container) if err != nil { @@ -76,6 +74,7 @@ func Inspect(ctx context.Context, client *containerd.Client, options types.Netwo if nativeContainer.Process == nil || nativeContainer.Process.Status.Status != containerd.Running { continue } + containers = append(containers, nativeContainer) } diff --git a/pkg/cmd/network/list.go b/pkg/cmd/network/list.go index 731c51b1b99..d27333a3321 100644 --- a/pkg/cmd/network/list.go +++ b/pkg/cmd/network/list.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "text/tabwriter" "text/template" @@ -147,21 +148,21 @@ func getNetworkFilterFuncs(filters []string) ([]func(*map[string]string) bool, [ for _, filter := range filters { if strings.HasPrefix(filter, "name") || strings.HasPrefix(filter, "label") { - subs := strings.SplitN(filter, "=", 2) - if len(subs) < 2 { + filter, value, ok := strings.Cut(filter, "=") + if !ok { continue } - switch subs[0] { + switch filter { case "name": + re, err := regexp.Compile(value) + if err != nil { + return nil, nil, err + } nameFilterFuncs = append(nameFilterFuncs, func(name string) bool { - return strings.Contains(name, subs[1]) + return re.MatchString(name) }) case "label": - v, k, hasValue := "", subs[1], false - if subs := strings.SplitN(subs[1], "=", 2); len(subs) == 2 { - hasValue = true - k, v = subs[0], subs[1] - } + k, v, hasValue := strings.Cut(value, "=") labelFilterFuncs = append(labelFilterFuncs, func(labels *map[string]string) bool { if labels == nil { return false diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go new file mode 100644 index 00000000000..e0db9206ddc --- /dev/null +++ b/pkg/cmd/search/search.go @@ -0,0 +1,271 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package search + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "text/tabwriter" + + dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +type SearchResult struct { + Description string `json:"description"` + IsOfficial bool `json:"is_official"` + Name string `json:"name"` + StarCount int `json:"star_count"` +} + +func Search(ctx context.Context, term string, options types.SearchOptions) error { + // Validate filters before making HTTP request + filterMap, err := validateAndParseFilters(options.Filters) + if err != nil { + return err + } + + registryHost, searchTerm := splitReposSearchTerm(term) + + parsedRef, err := referenceutil.Parse(registryHost) + if err != nil { + log.G(ctx).WithError(err).Debugf("failed to parse registry host %q, using as-is", registryHost) + } else { + registryHost = parsedRef.Domain + } + + var dOpts []dockerconfigresolver.Opt + + if options.GOptions.InsecureRegistry { + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", registryHost) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) + + hostOpts, err := dockerconfigresolver.NewHostOptions(ctx, registryHost, dOpts...) + if err != nil { + return fmt.Errorf("failed to create host options: %w", err) + } + + username, password, err := hostOpts.Credentials(registryHost) + if err != nil { + log.G(ctx).WithError(err).Debug("no credentials found, searching anonymously") + } + + scheme := "https" + if hostOpts.DefaultScheme != "" { + scheme = hostOpts.DefaultScheme + } + + searchURL := buildSearchURL(registryHost, searchTerm, scheme) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return err + } + + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } + + client := createHTTPClient(hostOpts) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("search failed with status %d: %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + Results []SearchResult `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return fmt.Errorf("failed to decode search response: %w", err) + } + + filteredResults := applyFilters(searchResp.Results, filterMap, options.Limit) + + return printSearchResults(options.Stdout, filteredResults, options) +} + +func splitReposSearchTerm(reposName string) (registryHost string, searchTerm string) { + nameParts := strings.SplitN(reposName, "/", 2) + if len(nameParts) == 1 || + (!strings.Contains(nameParts[0], ".") && + !strings.Contains(nameParts[0], ":") && + nameParts[0] != "localhost") { + // No registry specified, use docker.io + // For "library/alpine", the search term should be "alpine" + // For "alpine", the search term should be "alpine" + if len(nameParts) == 2 && nameParts[0] == "library" { + return "docker.io", nameParts[1] + } + return "docker.io", reposName + } + return nameParts[0], nameParts[1] +} + +func buildSearchURL(registryHost, term, scheme string) string { + host := registryHost + if host == "docker.io" { + host = "index.docker.io" + } + + u := url.URL{ + Scheme: scheme, + Host: host, + Path: "/v1/search", + } + q := u.Query() + q.Set("q", term) + u.RawQuery = q.Encode() + + return u.String() +} + +func createHTTPClient(hostOpts *dockerconfig.HostOptions) *http.Client { + if hostOpts != nil && hostOpts.DefaultTLS != nil { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: hostOpts.DefaultTLS, + }, + } + } + return http.DefaultClient +} + +func validateFilterValue(key, value string) error { + switch key { + case "stars": + if _, err := strconv.Atoi(value); err != nil { + return fmt.Errorf("invalid filter 'stars=%s'", value) + } + case "is-official": + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("invalid filter 'is-official=%s'", value) + } + default: + return fmt.Errorf("invalid filter '%s'", key) + } + return nil +} + +// validateAndParseFilters validates and parses filters before making HTTP request +func validateAndParseFilters(filters []string) (map[string]string, error) { + filterMap := make(map[string]string) + for _, f := range filters { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("bad format of filter (expected name=value)") + } + key := parts[0] + value := parts[1] + if err := validateFilterValue(key, value); err != nil { + return nil, err + } + filterMap[key] = value + } + return filterMap, nil +} + +func applyFilters(results []SearchResult, filterMap map[string]string, limit int) []SearchResult { + filtered := make([]SearchResult, 0, len(results)) + + for _, r := range results { + if val, ok := filterMap["is-official"]; ok { + b, _ := strconv.ParseBool(val) + if b != r.IsOfficial { + continue + } + } + + if val, ok := filterMap["stars"]; ok { + stars, _ := strconv.Atoi(val) + if r.StarCount < stars { + continue + } + } + + filtered = append(filtered, r) + } + + // Apply limit after filtering, but maintain original order from API + if limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + + return filtered +} + +func truncateDescription(desc string, noTrunc bool) string { + if !noTrunc && len(desc) > 45 { + return formatter.Ellipsis(desc, 45) + } + return desc +} + +func printSearchResults(stdout io.Writer, results []SearchResult, options types.SearchOptions) error { + for i := range results { + results[i].Description = truncateDescription(results[i].Description, options.NoTrunc) + } + + if options.Format != "" { + tmpl, err := formatter.ParseTemplate(options.Format) + if err != nil { + return err + } + for _, r := range results { + if err := tmpl.Execute(stdout, r); err != nil { + return err + } + fmt.Fprintln(stdout) + } + return nil + } + + w := tabwriter.NewWriter(stdout, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tSTARS\tOFFICIAL") + + for _, r := range results { + desc := strings.ReplaceAll(r.Description, "\n", " ") + desc = strings.ReplaceAll(desc, "\t", " ") + + official := "" + if r.IsOfficial { + official = "[OK]" + } + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", r.Name, desc, r.StarCount, official) + } + return w.Flush() +} diff --git a/pkg/cmd/volume/list.go b/pkg/cmd/volume/list.go index bb0654ba5b2..126b65b9045 100644 --- a/pkg/cmd/volume/list.go +++ b/pkg/cmd/volume/list.go @@ -20,6 +20,7 @@ import ( "bytes" "errors" "fmt" + "regexp" "strconv" "strings" "text/tabwriter" @@ -216,21 +217,21 @@ func getVolumeFilterFuncs(filters []string) ([]func(*map[string]string) bool, [] } for _, filter := range filters { if strings.HasPrefix(filter, "name") || strings.HasPrefix(filter, "label") { - subs := strings.SplitN(filter, "=", 2) - if len(subs) < 2 { + filter, value, ok := strings.Cut(filter, "=") + if !ok { continue } - switch subs[0] { + switch filter { case "name": + re, err := regexp.Compile(value) + if err != nil { + return nil, nil, nil, false, err + } nameFilterFuncs = append(nameFilterFuncs, func(name string) bool { - return strings.Contains(name, subs[1]) + return re.MatchString(name) }) case "label": - v, k, hasValue := "", subs[1], false - if subs := strings.SplitN(subs[1], "=", 2); len(subs) == 2 { - hasValue = true - k, v = subs[0], subs[1] - } + k, v, hasValue := strings.Cut(value, "=") labelFilterFuncs = append(labelFilterFuncs, func(labels *map[string]string) bool { if labels == nil { return false diff --git a/pkg/composer/build.go b/pkg/composer/build.go index 17b3fd0d8cd..780c7d8c319 100644 --- a/pkg/composer/build.go +++ b/pkg/composer/build.go @@ -63,6 +63,23 @@ func (c *Composer) buildServiceImage(ctx context.Context, image string, b *servi if bo.Progress != "" { args = append(args, "--progress="+bo.Progress) } + + if b.DockerfileInline != "" { + // if DockerfileInline is specified, write it to a temporary file + // and use -f flag to use that docker file with project's ctxdir + tmpFile, err := os.CreateTemp("", "inline-dockerfile-*.Dockerfile") + if err != nil { + return fmt.Errorf("failed to create temp file for DockerfileInline: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + if _, err := tmpFile.Write([]byte(b.DockerfileInline)); err != nil { + return fmt.Errorf("failed to write DockerfileInline: %w", err) + } + b.BuildArgs = append(b.BuildArgs, "-f="+tmpFile.Name()) + } + args = append(args, b.BuildArgs...) cmd := c.createNerdctlCmd(ctx, append([]string{"build"}, args...)...) diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index 539971d1f7e..a645d07e34a 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/identifiers" "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) @@ -54,7 +55,7 @@ type Options struct { IPFSAddress string } -func New(o Options, client *containerd.Client) (*Composer, error) { +func New(o Options, client *containerd.Client, cfg *config.Config) (*Composer, error) { if o.NerdctlCmd == "" { return nil, errors.New("got empty nerdctl cmd") } @@ -119,6 +120,7 @@ func New(o Options, client *containerd.Client) (*Composer, error) { Options: o, project: project, client: client, + config: cfg, } return c, nil @@ -128,6 +130,7 @@ type Composer struct { Options project *compose.Project client *containerd.Client + config *config.Config } func (c *Composer) createNerdctlCmd(ctx context.Context, args ...string) *exec.Cmd { diff --git a/pkg/composer/config.go b/pkg/composer/config.go index 41a5320daf8..c2f6ee583ec 100644 --- a/pkg/composer/config.go +++ b/pkg/composer/config.go @@ -32,7 +32,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/opencontainers/go-digest" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) type ConfigOptions struct { diff --git a/pkg/composer/create.go b/pkg/composer/create.go index 8b15c4823f6..ba7b58b77a7 100644 --- a/pkg/composer/create.go +++ b/pkg/composer/create.go @@ -29,6 +29,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" ) @@ -187,10 +188,15 @@ func (c *Composer) createServiceContainer(ctx context.Context, service *servicep cidFilename := filepath.Join(tempDir, "cid") //add metadata labels to container https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels + currentHash, err := ServiceHash(*service.Unparsed) + if err != nil { + return "", fmt.Errorf("failed computing service hash for %s: %w", container.Name, err) + } container.RunArgs = append([]string{ "--cidfile=" + cidFilename, fmt.Sprintf("-l=%s=%s", labels.ComposeProject, c.project.Name), fmt.Sprintf("-l=%s=%s", labels.ComposeService, service.Unparsed.Name), + fmt.Sprintf("-l=%s=%s", labels.ComposeConfigHash, currentHash), }, container.RunArgs...) cmd := c.createNerdctlCmd(ctx, append([]string{"create"}, container.RunArgs...)...) @@ -208,7 +214,7 @@ func (c *Composer) createServiceContainer(ctx context.Context, service *servicep return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) } - cid, err := os.ReadFile(cidFilename) + cid, err := filesystem.ReadFile(cidFilename) if err != nil { return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) } diff --git a/pkg/composer/down.go b/pkg/composer/down.go index 6996f1bda33..d7d7c2f0f6b 100644 --- a/pkg/composer/down.go +++ b/pkg/composer/down.go @@ -65,7 +65,7 @@ func (c *Composer) Down(ctx context.Context, downOptions DownOptions) error { return fmt.Errorf("error removeing orphaned containers: %w", err) } } else { - log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), orphans) + log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), containerShortIDs(orphans)) } } diff --git a/pkg/composer/exec.go b/pkg/composer/exec.go index 4e34bfa2a86..bd4d0d8b8d2 100644 --- a/pkg/composer/exec.go +++ b/pkg/composer/exec.go @@ -49,6 +49,11 @@ type ExecOptions struct { // Exec executes a given command on a running container specified by // `ServiceName` (and `Index` if it has multiple instances). func (c *Composer) Exec(ctx context.Context, eo ExecOptions) error { + // Exec does not need to lock and should allow concurrency. + if err := Unlock(); err != nil { + return err + } + containers, err := c.Containers(ctx, eo.ServiceName) if err != nil { return fmt.Errorf("fail to get containers for service %s: %w", eo.ServiceName, err) diff --git a/pkg/composer/lock.go b/pkg/composer/lock.go index 8fedda7bfc4..9006eca4bb3 100644 --- a/pkg/composer/lock.go +++ b/pkg/composer/lock.go @@ -20,7 +20,7 @@ import ( "os" "github.com/containerd/nerdctl/v2/pkg/clientutil" - "github.com/containerd/nerdctl/v2/pkg/lockutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) //nolint:unused @@ -39,10 +39,10 @@ func Lock(dataRoot string, address string) error { if err != nil { return err } - locked, err = lockutil.Lock(dataStore) + locked, err = filesystem.Lock(dataStore) return err } func Unlock() error { - return lockutil.Unlock(locked) + return filesystem.Unlock(locked) } diff --git a/pkg/composer/orphans.go b/pkg/composer/orphans.go index cd45386fa0d..307f31c545b 100644 --- a/pkg/composer/orphans.go +++ b/pkg/composer/orphans.go @@ -55,3 +55,11 @@ func (c *Composer) getOrphanContainers(ctx context.Context, parsedServices []*se return orphanContainers, nil } + +func containerShortIDs(containers []containerd.Container) []string { + names := make([]string, 0, len(containers)) + for _, c := range containers { + names = append(names, c.ID()[:12]) + } + return names +} diff --git a/pkg/composer/pause.go b/pkg/composer/pause.go index d0e7bc5aa77..3c26d50acb7 100644 --- a/pkg/composer/pause.go +++ b/pkg/composer/pause.go @@ -83,7 +83,7 @@ func (c *Composer) Unpause(ctx context.Context, services []string, writer io.Wri for _, container := range containers { container := container eg.Go(func() error { - if err := containerutil.Unpause(ctx, c.client, container.ID()); err != nil { + if err := containerutil.Unpause(ctx, c.client, container.ID(), c.config, c.NerdctlCmd, c.NerdctlArgs); err != nil { return err } info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata) diff --git a/pkg/composer/port.go b/pkg/composer/port.go index f786b4a3923..db2dac8befb 100644 --- a/pkg/composer/port.go +++ b/pkg/composer/port.go @@ -22,6 +22,7 @@ import ( "io" "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/portutil" ) // PortOptions has args for getting the public port of a given private port/protocol @@ -31,6 +32,8 @@ type PortOptions struct { Index int Port int Protocol string + DataStore string + Namespace string } // Port gets the corresponding public port of a given private port/protocol @@ -48,6 +51,13 @@ func (c *Composer) Port(ctx context.Context, writer io.Writer, po PortOptions) e po.Index, len(containers), po.ServiceName) } container := containers[po.Index-1] - - return containerutil.PrintHostPort(ctx, writer, container, po.Port, po.Protocol) + containerLabels, err := container.Labels(ctx) + if err != nil { + return err + } + ports, err := portutil.LoadPortMappings(po.DataStore, po.Namespace, container.ID(), containerLabels) + if err != nil { + return err + } + return containerutil.PrintHostPort(ctx, writer, container, po.Port, po.Protocol, ports) } diff --git a/pkg/composer/run.go b/pkg/composer/run.go index 9928fbd8d5d..84807cd6dc6 100644 --- a/pkg/composer/run.go +++ b/pkg/composer/run.go @@ -201,7 +201,7 @@ func (c *Composer) Run(ctx context.Context, ro RunOptions) error { return fmt.Errorf("error removing orphaned containers: %w", err) } } else { - log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), orphans) + log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), containerShortIDs(orphans)) } } diff --git a/pkg/composer/serviceparser/build.go b/pkg/composer/serviceparser/build.go index c13b264301a..98839a5c396 100644 --- a/pkg/composer/serviceparser/build.go +++ b/pkg/composer/serviceparser/build.go @@ -34,7 +34,7 @@ import ( func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName string) (*Build, error) { if unknown := reflectutil.UnknownNonEmptyFields(c, - "Context", "Dockerfile", "Args", "CacheFrom", "Target", "Labels", "Secrets", + "Context", "Dockerfile", "Args", "CacheFrom", "Target", "Labels", "Secrets", "DockerfileInline", "AdditionalContexts", ); len(unknown) > 0 { log.L.Warnf("Ignoring: build: %+v", unknown) } @@ -60,6 +60,10 @@ func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName st } } + if c.DockerfileInline != "" { + b.DockerfileInline = c.DockerfileInline + } + for k, v := range c.Args { if v == nil { b.BuildArgs = append(b.BuildArgs, "--build-arg="+k) @@ -72,6 +76,10 @@ func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName st b.BuildArgs = append(b.BuildArgs, "--cache-from="+s) } + for k, v := range c.AdditionalContexts { + b.BuildArgs = append(b.BuildArgs, "--build-context="+k+"="+v) + } + if c.Target != "" { b.BuildArgs = append(b.BuildArgs, "--target="+c.Target) } diff --git a/pkg/composer/serviceparser/build_test.go b/pkg/composer/serviceparser/build_test.go index 34af7143aec..e152296c242 100644 --- a/pkg/composer/serviceparser/build_test.go +++ b/pkg/composer/serviceparser/build_test.go @@ -18,6 +18,7 @@ package serviceparser import ( "runtime" + "strings" "testing" "gotest.tools/v3/assert" @@ -54,6 +55,12 @@ services: target: tgt_secret - simple_secret - absolute_secret + baz: + image: bazimg + build: + context: ./bazctx + dockerfile_inline: | + FROM random secrets: src_secret: file: test_secret1 @@ -95,4 +102,17 @@ secrets: assert.Assert(t, in(bar.Build.BuildArgs, "--secret=id=tgt_secret,src="+secretPath+"/test_secret1")) assert.Assert(t, in(bar.Build.BuildArgs, "--secret=id=simple_secret,src="+secretPath+"/test_secret2")) assert.Assert(t, in(bar.Build.BuildArgs, "--secret=id=absolute_secret,src=/tmp/absolute_secret")) + + bazSvc, err := project.GetService("baz") + assert.NilError(t, err) + + baz, err := Parse(project, bazSvc) + assert.NilError(t, err) + + t.Logf("baz: %+v", baz) + t.Logf("baz.Build.BuildArgs: %+v", baz.Build.BuildArgs) + t.Logf("baz.Build.DockerfileInline: %q", baz.Build.DockerfileInline) + assert.Assert(t, func() bool { + return strings.TrimSpace(baz.Build.DockerfileInline) == "FROM random" + }()) } diff --git a/pkg/composer/serviceparser/serviceparser.go b/pkg/composer/serviceparser/serviceparser.go index 971e4d8041b..afd665fca6f 100644 --- a/pkg/composer/serviceparser/serviceparser.go +++ b/pkg/composer/serviceparser/serviceparser.go @@ -30,7 +30,6 @@ import ( "github.com/compose-spec/compose-go/v2/types" - "github.com/containerd/containerd/v2/contrib/nvidia" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/identifiers" @@ -195,8 +194,9 @@ type Container struct { } type Build struct { - Force bool // force build even if already present - BuildArgs []string // {"-t", "example.com/foo", "--target", "foo", "/path/to/ctx"} + Force bool // force build even if already present + BuildArgs []string // {"-t", "example.com/foo", "--target", "foo", "/path/to/ctx"} + DockerfileInline string // store contents of dockerfile_inline field is specified // TODO: call BuildKit API directly without executing `nerdctl build` } @@ -261,9 +261,17 @@ func getMemLimit(svc types.ServiceConfig) (types.UnitBytes, error) { func getGPUs(svc types.ServiceConfig) (reqs []string, _ error) { // "gpu" and "nvidia" are also allowed capabilities (but not used as nvidia driver capabilities) // https://github.com/moby/moby/blob/v20.10.7/daemon/nvidia_linux.go#L37 - capset := map[string]struct{}{"gpu": {}, "nvidia": {}} - for _, c := range nvidia.AllCaps() { - capset[string(c)] = struct{}{} + capset := map[string]struct{}{ + "gpu": {}, "nvidia": {}, + // Allow the list of capabilities here (excluding "all" and "none") + // https://github.com/NVIDIA/nvidia-container-toolkit/blob/ff7c2d4866a7d46d1bf2a83590b263e10ec99cb5/internal/config/image/capabilities.go#L28-L38 + "compat32": {}, + "compute": {}, + "display": {}, + "graphics": {}, + "ngx": {}, + "utility": {}, + "video": {}, } if svc.Deploy != nil && svc.Deploy.Resources.Reservations != nil { for _, dev := range svc.Deploy.Resources.Reservations.Devices { @@ -450,7 +458,7 @@ func Parse(project *types.Project, svc types.ServiceConfig) (*Service, error) { parsed.Build.Force = true parsed.PullMode = "never" default: - log.L.Warnf("Ignoring: service %s: pull_policy: %q", svc.Name, svc.PullPolicy) + return nil, fmt.Errorf("invalid --pull option %q", svc.PullPolicy) } for i := 0; i < replicas; i++ { @@ -698,7 +706,14 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e if err != nil { return nil, err } - c.RunArgs = append(c.RunArgs, "-v="+vStr) + + switch v.Type { + case "tmpfs": + c.RunArgs = append(c.RunArgs, "--tmpfs="+vStr) + default: + c.RunArgs = append(c.RunArgs, "-v="+vStr) + } + c.Mkdir = mkdir } @@ -777,6 +792,7 @@ func serviceVolumeConfigToFlagV(c types.ServiceVolumeConfig, project *types.Proj "ReadOnly", "Bind", "Volume", + "Tmpfs", ); len(unknown) > 0 { log.L.Warnf("Ignoring: volume: %+v", unknown) } @@ -799,6 +815,29 @@ func serviceVolumeConfigToFlagV(c types.ServiceVolumeConfig, project *types.Proj return "", nil, fmt.Errorf("volume target must be an absolute path, got %q", c.Target) } + if c.Type == "tmpfs" { + var opts []string + + if c.ReadOnly { + opts = append(opts, "ro") + } + if c.Tmpfs != nil { + if c.Tmpfs.Size != 0 { + opts = append(opts, fmt.Sprintf("size=%d", c.Tmpfs.Size)) + } + if c.Tmpfs.Mode != 0 { + opts = append(opts, fmt.Sprintf("mode=%o", c.Tmpfs.Mode)) + } + } + + s := c.Target + if len(opts) > 0 { + s = fmt.Sprintf("%s:%s", s, strings.Join(opts, ",")) + } + + return s, mkdir, nil + } + if c.Source == "" { // anonymous volume s := c.Target diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 7d30ad6a875..856d732cbf4 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -18,7 +18,6 @@ package serviceparser import ( "fmt" - "os" "path/filepath" "runtime" "strconv" @@ -27,6 +26,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/strutil" "github.com/containerd/nerdctl/v2/pkg/testutil" ) @@ -439,6 +439,43 @@ services: } } +func TestTmpfsVolumeLongSyntax(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + + const dockerComposeYAML = ` +services: + foo: + image: nginx:alpine + volumes: + - type: tmpfs + target: /target + read_only: true + tmpfs: + size: 2G + mode: 0o1770 +` + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) + assert.NilError(t, err) + + fooSvc, err := project.GetService("foo") + assert.NilError(t, err) + + foo, err := Parse(project, fooSvc) + assert.NilError(t, err) + + t.Logf("foo: %+v", foo) + for _, c := range foo.Containers { + assert.Assert(t, in(c.RunArgs, "--tmpfs=/target:ro,size=2147483648,mode=1770")) + } +} + func TestParseNetworkMode(t *testing.T) { t.Parallel() const dockerComposeYAML = ` @@ -521,7 +558,7 @@ configs: assert.NilError(t, err) for _, f := range []string{"secret1", "secret2", "secret3", "config1", "config2"} { - err = os.WriteFile(filepath.Join(project.WorkingDir, f), []byte("content-"+f), 0444) + err = filesystem.WriteFile(filepath.Join(project.WorkingDir, f), []byte("content-"+f), 0444) assert.NilError(t, err) } diff --git a/pkg/composer/up.go b/pkg/composer/up.go index 84da4535c0f..ec9155331bb 100644 --- a/pkg/composer/up.go +++ b/pkg/composer/up.go @@ -85,7 +85,7 @@ func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) erro var parsedServices []*serviceparser.Service // use WithServices to sort the services in dependency order - if err := c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { + forEachFn := func(name string, svc *types.ServiceConfig) error { if replicas, ok := uo.Scale[svc.Name]; ok { if svc.Deploy == nil { svc.Deploy = &types.DeployConfig{} @@ -98,7 +98,9 @@ func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) erro } parsedServices = append(parsedServices, ps) return nil - }); err != nil { + } + err := c.project.ForEachService(services, forEachFn) + if err != nil { return err } @@ -114,7 +116,7 @@ func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) erro return fmt.Errorf("error removing orphaned containers: %w", err) } } else { - log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), orphans) + log.G(ctx).Warnf("found %d orphaned containers: %v, you can run this command with the --remove-orphans flag to clean it up", len(orphans), containerShortIDs(orphans)) } } diff --git a/pkg/composer/up_service.go b/pkg/composer/up_service.go index 7f6adf9fac5..fb9b1ed9fbb 100644 --- a/pkg/composer/up_service.go +++ b/pkg/composer/up_service.go @@ -31,6 +31,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" ) @@ -154,6 +155,28 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse // delete container if it already exists if existingCid != "" { + // Default behavior for RecreateDiverged: compare stored hash with current service hash + if recreate == RecreateDiverged { + currentHash, err := ServiceHash(*service.Unparsed) + if err != nil { + return "", fmt.Errorf("failed computing service hash for %s: %w", container.Name, err) + } + con, err := c.client.LoadContainer(ctx, existingCid) + if err != nil { + return "", fmt.Errorf("failed to load container %s: %w", existingCid, err) + } + lbls, err := con.Labels(ctx) + if err != nil { + return "", fmt.Errorf("failed to read labels for %s: %w", existingCid, err) + } + if lbls[labels.ComposeConfigHash] == currentHash { + cmd := c.createNerdctlCmd(ctx, append([]string{"start"}, existingCid)...) + if err := c.executeUpCmd(ctx, cmd, container.Name, runFlagD, service.Unparsed.StdinOpen); err != nil { + return "", fmt.Errorf("error while starting existing container %s: %w", container.Name, err) + } + return existingCid, nil + } + } log.G(ctx).Debugf("Container %q already exists, deleting", container.Name) delCmd := c.createNerdctlCmd(ctx, "rm", "-f", container.Name) if err = delCmd.Run(); err != nil { @@ -183,10 +206,15 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse } //add metadata labels to container https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels + currentHash, err := ServiceHash(*service.Unparsed) + if err != nil { + return "", fmt.Errorf("failed computing service hash for %s: %w", container.Name, err) + } container.RunArgs = append([]string{ "--cidfile=" + cidFilename, fmt.Sprintf("-l=%s=%s", labels.ComposeProject, c.project.Name), fmt.Sprintf("-l=%s=%s", labels.ComposeService, service.Unparsed.Name), + fmt.Sprintf("-l=%s=%s", labels.ComposeConfigHash, currentHash), }, container.RunArgs...) cmd := c.createNerdctlCmd(ctx, append([]string{"run"}, container.RunArgs...)...) @@ -198,7 +226,7 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) } - cid, err := os.ReadFile(cidFilename) + cid, err := filesystem.ReadFile(cidFilename) if err != nil { return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index ce118de5edf..5ecf41b9256 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,9 +41,12 @@ type Config struct { HostGatewayIP string `toml:"host_gateway_ip"` BridgeIP string `toml:"bridge_ip, omitempty"` KubeHideDupe bool `toml:"kube_hide_dupe"` - // CDISpecDirs is a list of directories in which CDI specifications can be found. - CDISpecDirs []string `toml:"cdi_spec_dirs,omitempty"` - UsernsRemap string `toml:"userns_remap, omitempty"` + CDISpecDirs []string `toml:"cdi_spec_dirs,omitempty"` // CDISpecDirs is a list of directories in which CDI specifications can be found. + UsernsRemap string `toml:"userns_remap, omitempty"` + DNS []string `toml:"dns,omitempty"` + DNSOpts []string `toml:"dns_opts,omitempty"` + DNSSearch []string `toml:"dns_search,omitempty"` + DisableHCSystemd bool `toml:"disable_hc_systemd"` } // New creates a default Config object statically, @@ -66,5 +69,9 @@ func New() *Config { KubeHideDupe: false, CDISpecDirs: ncdefaults.CDISpecDirs(), UsernsRemap: "", + DNS: []string{}, + DNSOpts: []string{}, + DNSSearch: []string{}, + DisableHCSystemd: false, } } diff --git a/pkg/containerdutil/version.go b/pkg/containerdutil/version.go new file mode 100644 index 00000000000..6880c918efa --- /dev/null +++ b/pkg/containerdutil/version.go @@ -0,0 +1,53 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package containerdutil + +import ( + "context" + "fmt" + + "github.com/Masterminds/semver/v3" + + containerd "github.com/containerd/containerd/v2/client" +) + +func ServerSemVer(ctx context.Context, client *containerd.Client) (*semver.Version, error) { + v, err := client.Version(ctx) + if err != nil { + return nil, err + } + sv, err := semver.NewVersion(v.Version) + if err != nil { + return nil, fmt.Errorf("failed to parse the containerd version %q: %w", v.Version, err) + } + return sv, nil +} + +// SupportsFullTransferService checks if the containerd version fully supports the Transfer service. +// While containerd 1.7 has Transfer service, full support is only available in 2.0+. +// The following features are missing in containerd 1.7: +// - Non-distributable artifacts support +// - Registry configuration options: WithHostDir(), WithDefaultScheme() etc. +func SupportsFullTransferService(ctx context.Context, client *containerd.Client) bool { + sv, err := ServerSemVer(ctx, client) + if err != nil { + // If we can't determine version, assume it's an older version for safety + return false + } + v20, _ := semver.NewVersion("2.0.0") + return !sv.LessThan(v20) +} diff --git a/pkg/containerutil/container_network_manager.go b/pkg/containerutil/container_network_manager.go index 9f082f9ce12..3638b450917 100644 --- a/pkg/containerutil/container_network_manager.go +++ b/pkg/containerutil/container_network_manager.go @@ -39,6 +39,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/mountutil" "github.com/containerd/nerdctl/v2/pkg/netutil" @@ -88,7 +89,7 @@ func withCustomHosts(src string) func(context.Context, oci.Client, *containers.C } } -func fetchDNSResolverConfig(netOpts types.NetworkOptions) ([]string, []string, []string, error) { +func fetchDNSResolverConfig(netOpts types.NetworkOptions, allowLocalhostDNS bool) ([]string, []string, []string, error) { dns := netOpts.DNSServers dnsSearch := netOpts.DNSSearchDomains dnsOptions := netOpts.DNSResolvConfOptions @@ -102,7 +103,7 @@ func fetchDNSResolverConfig(netOpts types.NetworkOptions) ([]string, []string, [ conf = &resolvconf.File{} log.L.WithError(err).Debugf("resolvConf file doesn't exist on host") } - conf, err = resolvconf.FilterResolvDNS(conf.Content, true) + conf, err = resolvconf.FilterResolvDNSWithLocalhostOption(conf.Content, true, allowLocalhostDNS) if err != nil { return nil, nil, nil, err } @@ -290,7 +291,7 @@ func (m *noneNetworkManager) ContainerNetworkingOpts(_ context.Context, containe } resolvConfPath := filepath.Join(stateDir, "resolv.conf") - dns, dnsSearch, dnsOptions, err := fetchDNSResolverConfig(m.netOpts) + dns, dnsSearch, dnsOptions, err := fetchDNSResolverConfig(m.netOpts, false) if err != nil { return nil, nil, err } @@ -670,7 +671,7 @@ func (m *hostNetworkManager) ContainerNetworkingOpts(_ context.Context, containe } resolvConfPath := filepath.Join(stateDir, "resolv.conf") - dns, dnsSearch, dnsOptions, err := fetchDNSResolverConfig(m.netOpts) + dns, dnsSearch, dnsOptions, err := fetchDNSResolverConfig(m.netOpts, true) if err != nil { return nil, nil, err } @@ -685,7 +686,7 @@ func (m *hostNetworkManager) ContainerNetworkingOpts(_ context.Context, containe return nil, nil, err } - content, err := os.ReadFile("/etc/hosts") + content, err := filesystem.ReadFile("/etc/hosts") if err != nil { return nil, nil, err } @@ -829,7 +830,7 @@ func writeEtcHostnameForContainer(globalOptions types.GlobalCommandOptions, host } hostnamePath := filepath.Join(stateDir, "hostname") - if err := os.WriteFile(hostnamePath, []byte(hostname+"\n"), 0644); err != nil { + if err := filesystem.WriteFile(hostnamePath, []byte(hostname+"\n"), 0644); err != nil { return nil, err } @@ -892,12 +893,6 @@ func NetworkOptionsFromSpec(spec *specs.Spec) (types.NetworkOptions, error) { } opts.NetworkSlice = networks - if portsJSON := spec.Annotations[labels.Ports]; portsJSON != "" { - if err := json.Unmarshal([]byte(portsJSON), &opts.PortMappings); err != nil { - return opts, err - } - } - return opts, nil } diff --git a/pkg/containerutil/containerutil.go b/pkg/containerutil/containerutil.go index 0bebf2310ea..875f202fbda 100644 --- a/pkg/containerutil/containerutil.go +++ b/pkg/containerutil/containerutil.go @@ -42,15 +42,17 @@ import ( "github.com/containerd/containerd/v2/pkg/cio" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/errdefs" + "github.com/containerd/go-cni" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/config" "github.com/containerd/nerdctl/v2/pkg/consoleutil" "github.com/containerd/nerdctl/v2/pkg/errutil" "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/ipcutil" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/labels/k8slabels" - "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/signalutil" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -59,16 +61,7 @@ import ( // PrintHostPort writes to `writer` the public (HostIP:HostPort) of a given `containerPort/protocol` in a container. // if `containerPort < 0`, it writes all public ports of the container. -func PrintHostPort(ctx context.Context, writer io.Writer, container containerd.Container, containerPort int, proto string) error { - l, err := container.Labels(ctx) - if err != nil { - return err - } - ports, err := portutil.ParsePortsLabel(l) - if err != nil { - return err - } - +func PrintHostPort(ctx context.Context, writer io.Writer, container containerd.Container, containerPort int, proto string, ports []cni.PortMapping) error { if containerPort < 0 { for _, p := range ports { fmt.Fprintf(writer, "%d/%s -> %s:%d\n", p.ContainerPort, p.Protocol, p.HostIP, p.HostPort) @@ -213,7 +206,7 @@ func GenerateSharingPIDOpts(ctx context.Context, targetCon containerd.Container) } // Start starts `container` with `attach` flag. If `attach` is true, it will attach to the container's stdio. -func Start(ctx context.Context, container containerd.Container, flagA bool, flagI bool, client *containerd.Client, detachKeys string) (err error) { +func Start(ctx context.Context, container containerd.Container, isAttach bool, isInteractive bool, client *containerd.Client, detachKeys string, checkpointDir string, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) (err error) { // defer the storage of start error in the dedicated label defer func() { if err != nil { @@ -225,6 +218,9 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, flag return err } + if _, ok := lab[k8slabels.ContainerType]; ok { + log.L.Warnf("nerdctl does not support starting container %s created by Kubernetes", container.ID()) + } if err := ReconfigNetContainer(ctx, container, client, lab); err != nil { return err } @@ -241,9 +237,9 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, flag if err != nil { return err } - flagT := process.Process.Terminal + isTerminal := process.Process.Terminal var con console.Console - if (flagI || flagA) && flagT { + if (isInteractive || isAttach) && isTerminal { con, err = consoleutil.Current() if err != nil { return err @@ -279,34 +275,52 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, flag } detachC := make(chan struct{}) attachStreamOpt := []string{} - if flagA { - // In start, flagA attaches only STDOUT/STDERR + if isAttach { + // In start, isAttach attaches only STDOUT/STDERR // source: https://github.com/containerd/nerdctl/blob/main/docs/command-reference.md#whale-nerdctl-start attachStreamOpt = []string{"STDOUT", "STDERR"} } - task, err := taskutil.NewTask(ctx, client, container, attachStreamOpt, flagI, flagT, true, con, logURI, detachKeys, namespace, detachC) + task, err := taskutil.NewTask(ctx, client, container, taskutil.TaskOptions{ + AttachStreamOpt: attachStreamOpt, + IsInteractive: isInteractive, + IsTerminal: isTerminal, + IsDetach: true, + Con: con, + LogURI: logURI, + DetachKeys: detachKeys, + Namespace: namespace, + DetachC: detachC, + CheckpointDir: checkpointDir, + }) + if err != nil { + return err + } + statusC, err := task.Wait(ctx) if err != nil { return err } - if err := task.Start(ctx); err != nil { return err } - if !flagA { + + // If container has health checks configured, create and start systemd timer/service files. + if err := healthcheck.CreateTimer(ctx, container, cfg, nerdctlCmd, nerdctlArgs); err != nil { + return fmt.Errorf("failed to create healthcheck timer: %w", err) + } + if err := healthcheck.StartTimer(ctx, container, cfg); err != nil { + return fmt.Errorf("failed to start healthcheck timer: %w", err) + } + + if !isAttach { return nil } - if flagA && flagT { + if isAttach && isTerminal { if err := consoleutil.HandleConsoleResize(ctx, task, con); err != nil { log.G(ctx).WithError(err).Error("console resize") } } sigc := signalutil.ForwardAllSignals(ctx, task) defer signalutil.StopCatch(sigc) - - statusC, err := task.Wait(ctx) - if err != nil { - return err - } select { // io.Wait() would return when either 1) the user detaches from the container OR 2) the container is about to exit. // @@ -394,6 +408,12 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur switch status.Status { case containerd.Created, containerd.Stopped: + // Cleanup the IO after a successful Stop + if io := task.IO(); io != nil { + if cerr := io.Close(); cerr != nil { + log.G(ctx).Warnf("failed to close IO for container %s: %v", container.ID(), cerr) + } + } return nil case containerd.Paused, containerd.Pausing: paused = true @@ -406,6 +426,13 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur return err } + // signal will be sent once resume is finished + if paused { + if err := task.Resume(ctx); err != nil { + log.G(ctx).Errorf("cannot unpause container %s: %s", container.ID(), err) + return err + } + } if *timeout > 0 { sig, err := getSignal(signalValue, l) if err != nil { @@ -416,20 +443,10 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur return err } - // signal will be sent once resume is finished - if paused { - if err := task.Resume(ctx); err != nil { - log.G(ctx).Warnf("Cannot unpause container %s: %s", container.ID(), err) - } else { - // no need to do it again when send sigkill signal - paused = false - } - } - sigtermCtx, sigtermCtxCancel := context.WithTimeout(ctx, *timeout) defer sigtermCtxCancel() - err = waitContainerStop(sigtermCtx, exitCh, container.ID()) + err = waitContainerStop(sigtermCtx, task, exitCh, container.ID()) if err == nil { return nil } @@ -448,13 +465,7 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur return err } - // signal will be sent once resume is finished - if paused { - if err := task.Resume(ctx); err != nil { - log.G(ctx).Warnf("Cannot unpause container %s: %s", container.ID(), err) - } - } - return waitContainerStop(ctx, exitCh, container.ID()) + return waitContainerStop(ctx, task, exitCh, container.ID()) } func getSignal(signalValue string, containerLabels map[string]string) (syscall.Signal, error) { @@ -469,7 +480,7 @@ func getSignal(signalValue string, containerLabels map[string]string) (syscall.S return signal.ParseSignal("SIGTERM") } -func waitContainerStop(ctx context.Context, exitCh <-chan containerd.ExitStatus, id string) error { +func waitContainerStop(ctx context.Context, task containerd.Task, exitCh <-chan containerd.ExitStatus, id string) error { select { case <-ctx.Done(): if err := ctx.Err(); err != nil { @@ -477,6 +488,12 @@ func waitContainerStop(ctx context.Context, exitCh <-chan containerd.ExitStatus, } return nil case status := <-exitCh: + // Cleanup the IO after a successful Stop + if io := task.IO(); io != nil { + if cerr := io.Close(); cerr != nil { + log.G(ctx).Warnf("failed to close IO for container %s: %v", id, cerr) + } + } return status.Error() } } @@ -509,7 +526,7 @@ func Pause(ctx context.Context, client *containerd.Client, id string) error { } // Unpause unpauses a container by its id. -func Unpause(ctx context.Context, client *containerd.Client, id string) error { +func Unpause(ctx context.Context, client *containerd.Client, id string, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) error { container, err := client.LoadContainer(ctx, id) if err != nil { return err @@ -525,6 +542,14 @@ func Unpause(ctx context.Context, client *containerd.Client, id string) error { return err } + // Recreate healthcheck related systemd timer/service files. + if err := healthcheck.CreateTimer(ctx, container, cfg, nerdctlCmd, nerdctlArgs); err != nil { + return fmt.Errorf("failed to create healthcheck timer: %w", err) + } + if err := healthcheck.StartTimer(ctx, container, cfg); err != nil { + return fmt.Errorf("failed to start healthcheck timer: %w", err) + } + switch status.Status { case containerd.Paused: return task.Resume(ctx) diff --git a/pkg/dnsutil/dnsutil.go b/pkg/dnsutil/dnsutil.go index 433a19b324b..370ad23db24 100644 --- a/pkg/dnsutil/dnsutil.go +++ b/pkg/dnsutil/dnsutil.go @@ -18,6 +18,9 @@ package dnsutil import ( "context" + "fmt" + "net" + "strings" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) @@ -39,3 +42,14 @@ func GetSlirp4netnsDNS() ([]string, error) { } return dns, nil } + +// ValidateIPAddress validates if the given value is a correctly formatted +// IP address, and returns the value in normalized form. Leading and trailing +// whitespace is allowed, but it does not allow IPv6 addresses surrounded by +// square brackets ("[::1]"). Refer to [net.ParseIP] for accepted formats. +func ValidateIPAddress(val string) (string, error) { + if ip := net.ParseIP(strings.TrimSpace(val)); ip != nil { + return ip.String(), nil + } + return "", fmt.Errorf("ip address is not correctly formatted: %q", val) +} diff --git a/pkg/dnsutil/dnsutil_test.go b/pkg/dnsutil/dnsutil_test.go new file mode 100644 index 00000000000..17a8e8813e2 --- /dev/null +++ b/pkg/dnsutil/dnsutil_test.go @@ -0,0 +1,105 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dnsutil + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestValidateIPAddress(t *testing.T) { + tests := []struct { + name string + input string + expectedOut string + expectedErr string + }{ + { + name: "IPv4 loopback", + input: `127.0.0.1`, + expectedOut: `127.0.0.1`, + }, + { + name: "IPv4 loopback with whitespace", + input: ` 127.0.0.1 `, + expectedOut: `127.0.0.1`, + }, + { + name: "IPv6 loopback long form", + input: `0:0:0:0:0:0:0:1`, + expectedOut: `::1`, + }, + { + name: "IPv6 loopback", + input: `::1`, + expectedOut: `::1`, + }, + { + name: "IPv6 loopback with whitespace", + input: ` ::1 `, + expectedOut: `::1`, + }, + { + name: "IPv6 lowercase", + input: `2001:db8::68`, + expectedOut: `2001:db8::68`, + }, + { + name: "IPv6 uppercase", + input: `2001:DB8::68`, + expectedOut: `2001:db8::68`, + }, + { + name: "IPv6 with brackets", + input: `[::1]`, + expectedErr: `ip address is not correctly formatted: "[::1]"`, + }, + { + name: "IPv4 partial", + input: `127`, + expectedErr: `ip address is not correctly formatted: "127"`, + }, + { + name: "random invalid string", + input: `random invalid string`, + expectedErr: `ip address is not correctly formatted: "random invalid string"`, + }, + { + name: "empty string", + input: ``, + expectedErr: `ip address is not correctly formatted: ""`, + }, + { + name: "only whitespace", + input: ` `, + expectedErr: `ip address is not correctly formatted: " "`, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + actualOut, actualErr := ValidateIPAddress(tc.input) + assert.Equal(t, tc.expectedOut, actualOut) + if tc.expectedErr == "" { + assert.Check(t, actualErr) + } else { + assert.Equal(t, tc.expectedErr, actualErr.Error()) + } + }) + } +} diff --git a/pkg/dnsutil/hostsstore/hostsstore.go b/pkg/dnsutil/hostsstore/hostsstore.go index de50043f366..991a4929c9c 100644 --- a/pkg/dnsutil/hostsstore/hostsstore.go +++ b/pkg/dnsutil/hostsstore/hostsstore.go @@ -40,6 +40,7 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/store" ) @@ -116,11 +117,11 @@ func (x *hostsStore) Acquire(meta Meta) (err error) { // Because of the way we call network manager ContainerNetworkingOpts then SetupNetworking in sequence // we need to make sure we do not overwrite an already allocated hosts file. if _, err = os.Stat(loc); os.IsNotExist(err) { - if err = os.WriteFile(loc, []byte{}, 0o644); err != nil { + if err = filesystem.WriteFile(loc, []byte{}, 0o644); err != nil { return errors.Join(store.ErrSystemFailure, err) } - // os.WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched + // WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched // against the current process umask. // See https://www.man7.org/linux/man-pages/man2/open.2.html for details. // Since we must make sure that these files are world readable, explicitly chmod them here. @@ -185,12 +186,12 @@ func (x *hostsStore) AllocHostsFile(id string, content []byte) (location string, return err } - err = os.WriteFile(loc, content, 0o644) + err = filesystem.WriteFile(loc, content, 0o644) if err != nil { err = errors.Join(store.ErrSystemFailure, err) } - // os.WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched + // WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched // against the current process umask. // See https://www.man7.org/linux/man-pages/man2/open.2.html for details. // Since we must make sure that these files are world readable, explicitly chmod them here. @@ -351,7 +352,7 @@ func (x *hostsStore) updateAllHosts() (err error) { return err } - err = os.WriteFile(loc, buf.Bytes(), 0o644) + err = filesystem.WriteFile(loc, buf.Bytes(), 0o644) if err != nil { log.L.WithError(err).Errorf("failed to write hosts file for %q", entry) } diff --git a/pkg/errutil/errors_check.go b/pkg/errutil/errors_check.go index 202c4fe8518..8db2a166fcd 100644 --- a/pkg/errutil/errors_check.go +++ b/pkg/errutil/errors_check.go @@ -24,3 +24,18 @@ func IsErrConnectionRefused(err error) bool { const errMessage = "connect: connection refused" return strings.Contains(err.Error(), errMessage) } + +// IsErrHTTPResponseToHTTPSClient returns whether err is +// "http: server gave HTTP response to HTTPS client" +func IsErrHTTPResponseToHTTPSClient(err error) bool { + const errMessage = "server gave HTTP response to HTTPS client" + return strings.Contains(err.Error(), errMessage) +} + +// IsErrTLSHandshakeFailure returns whether err is a TLS handshake or certificate verification error +func IsErrTLSHandshakeFailure(err error) bool { + errStr := err.Error() + return strings.Contains(errStr, "tls:") || + strings.Contains(errStr, "x509:") || + strings.Contains(errStr, "certificate") +} diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index 3801e1ab208..ff25312f9a2 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -21,6 +21,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strconv" "strings" "time" @@ -33,9 +34,7 @@ import ( "github.com/containerd/containerd/v2/core/runtime/restart" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/errdefs" - "github.com/containerd/log" - - "github.com/containerd/nerdctl/v2/pkg/portutil" + "github.com/containerd/go-cni" ) func ContainerStatus(ctx context.Context, c containerd.Container) string { @@ -112,19 +111,61 @@ func Ellipsis(str string, maxDisplayWidth int) string { return str[:maxDisplayWidth-1] + "…" } -func FormatPorts(labelMap map[string]string) string { - ports, err := portutil.ParsePortsLabel(labelMap) - if err != nil { - log.L.Error(err.Error()) +func formatRange(startHost, endHost, startContainer, endContainer int32) string { + if startHost == endHost && startContainer == endContainer { + return fmt.Sprintf("%d->%d", startHost, startContainer) } + return fmt.Sprintf("%d-%d->%d-%d", startHost, endHost, startContainer, endContainer) +} + +func FormatPorts(ports []cni.PortMapping) string { if len(ports) == 0 { return "" } - strs := make([]string, len(ports)) - for i, p := range ports { - strs[i] = fmt.Sprintf("%s:%d->%d/%s", p.HostIP, p.HostPort, p.ContainerPort, p.Protocol) + + type key struct { + HostIP string + Protocol string + } + grouped := make(map[key][]cni.PortMapping) + + for _, p := range ports { + k := key{HostIP: p.HostIP, Protocol: p.Protocol} + grouped[k] = append(grouped[k], p) + } + + var displayPorts []string + for k, pms := range grouped { + sort.Slice(pms, func(i, j int) bool { + return pms[i].HostPort < pms[j].HostPort + }) + + var i int + var ranges []string + for i = 0; i < len(pms); { + start, end := pms[i], pms[i] + for i+1 < len(pms) && + pms[i+1].HostPort == end.HostPort+1 && + pms[i+1].ContainerPort == end.ContainerPort+1 { + i++ + end = pms[i] + } + + ranges = append( + ranges, + formatRange(start.HostPort, end.HostPort, start.ContainerPort, end.ContainerPort), + ) + i++ + } + displayPorts = append( + displayPorts, + fmt.Sprintf("%s:%s/%s", k.HostIP, strings.Join(ranges, ", "), k.Protocol), + ) } - return strings.Join(strs, ", ") + + sort.Strings(displayPorts) + + return strings.Join(displayPorts, ", ") } func TimeSinceInHuman(since time.Time) string { diff --git a/pkg/formatter/formatter_test.go b/pkg/formatter/formatter_test.go index 6e039039e11..9da5e6fcf11 100644 --- a/pkg/formatter/formatter_test.go +++ b/pkg/formatter/formatter_test.go @@ -21,6 +21,8 @@ import ( "time" "gotest.tools/v3/assert" + + "github.com/containerd/go-cni" ) func TestTimeSinceInHuman(t *testing.T) { @@ -87,3 +89,106 @@ func TestTimeSinceInHuman(t *testing.T) { }) } } + +func TestFormatPorts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []cni.PortMapping + expected string + }{ + { + name: "a single tcp port on localhost", + input: []cni.PortMapping{ + { + HostPort: 3000, + ContainerPort: 8080, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + }, + expected: "127.0.0.1:3000->8080/tcp", + }, + { + name: "consecutive tcp ports on localhost", + input: []cni.PortMapping{ + { + HostPort: 3000, + ContainerPort: 8080, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + { + HostPort: 3001, + ContainerPort: 8081, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + }, + expected: "127.0.0.1:3000-3001->8080-8081/tcp", + }, + { + name: "a single tcp port on anyhost", + input: []cni.PortMapping{ + { + HostPort: 3000, + ContainerPort: 8080, + Protocol: "tcp", + HostIP: "0.0.0.0", + }, + }, + expected: "0.0.0.0:3000->8080/tcp", + }, + { + name: "a single udp port on anyhost", + input: []cni.PortMapping{ + { + HostPort: 3000, + ContainerPort: 8080, + Protocol: "udp", + HostIP: "0.0.0.0", + }, + }, + expected: "0.0.0.0:3000->8080/udp", + }, + { + name: "mixed tcp and udp with consecutive ports on anyhost", + input: []cni.PortMapping{ + { + HostPort: 3000, + ContainerPort: 8080, + Protocol: "tcp", + HostIP: "0.0.0.0", + }, + { + HostPort: 3001, + ContainerPort: 8081, + Protocol: "tcp", + HostIP: "0.0.0.0", + }, + { + HostPort: 3002, + ContainerPort: 8082, + Protocol: "udp", + HostIP: "0.0.0.0", + }, + { + HostPort: 3003, + ContainerPort: 8083, + Protocol: "udp", + HostIP: "0.0.0.0", + }, + }, + expected: "0.0.0.0:3000-3001->8080-8081/tcp, 0.0.0.0:3002-3003->8082-8083/udp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := FormatPorts(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/fs/fs.go b/pkg/fs/fs.go new file mode 100644 index 00000000000..b9313806172 --- /dev/null +++ b/pkg/fs/fs.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package fs + +import "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" + +// InitFS will set the root location to store `internal/filesystem` ops files. +// These files are used to allow `WriteFile` to backup and rollback content. +// While they are transient in nature, they should still persist OS crashes / reboots, so, preferably under something +// like XDGData, rather than tmp. +func InitFS(path string) error { + return filesystem.SetFilesystemOpsDirectory(path) +} diff --git a/pkg/healthcheck/executor.go b/pkg/healthcheck/executor.go new file mode 100644 index 00000000000..2525ee7c54b --- /dev/null +++ b/pkg/healthcheck/executor.go @@ -0,0 +1,221 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "context" + "fmt" + "strings" + "syscall" + "time" + + "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/idgen" +) + +// ExecuteHealthCheck executes the health check command for a container +func ExecuteHealthCheck(ctx context.Context, task containerd.Task, container containerd.Container, hc *Healthcheck) error { + // Prepare process spec for health check command + processSpec, err := prepareProcessSpec(ctx, container, hc) + if err != nil { + return err + } + if processSpec == nil { + return nil + } + + startTime := time.Now() + result, err := probeHealthCheck(ctx, task, hc, processSpec) + if err != nil { + _ = updateHealthStatus(ctx, container, hc, &HealthcheckResult{ + Start: startTime, + End: time.Now(), + ExitCode: -1, + Output: err.Error(), + }) + return fmt.Errorf("health check probe failed: %w", err) + } + + // Success case, update health status + result.Start = startTime + if err := updateHealthStatus(ctx, container, hc, result); err != nil { + return fmt.Errorf("failed to update health status: %w", err) + } + return nil +} + +// probeHealthCheck executes the health check command inside the container context +func probeHealthCheck(ctx context.Context, task containerd.Task, hc *Healthcheck, processSpec *specs.Process) (*HealthcheckResult, error) { + execID := "health-check-" + idgen.TruncateID(idgen.GenerateID()) + outputBuf := NewResizableBuffer(MaxOutputLen) + + process, err := task.Exec(ctx, execID, processSpec, cio.NewCreator( + cio.WithStreams(nil, outputBuf, outputBuf), + )) + if err != nil { + log.G(ctx).Debugf("failed to exec health check: %v", err) + return nil, fmt.Errorf("exec error: %w", err) + } + + if err := process.Start(ctx); err != nil { + log.G(ctx).Debugf("failed to start health check: %v", err) + return nil, fmt.Errorf("start error: %w", err) + } + + exitStatusC, err := process.Wait(ctx) + if err != nil { + return nil, fmt.Errorf("failed to wait for health check: %w", err) + } + + select { + case <-time.After(hc.Timeout): + _ = process.Kill(ctx, syscall.SIGKILL) + <-exitStatusC + process.IO().Wait() + process.IO().Close() + msg := fmt.Sprintf("Health check exceeded timeout (%v)", hc.Timeout) + if out := outputBuf.String(); len(out) > 0 { + msg = fmt.Sprintf("Health check exceeded timeout (%v): %s", hc.Timeout, out) + } + + log.G(ctx).Debugf("health check timed out: %s", msg) + + return &HealthcheckResult{ + ExitCode: -1, + Output: msg, + End: time.Now(), + }, nil + + case exitStatus := <-exitStatusC: + process.IO().Wait() + process.IO().Close() + code, _, _ := exitStatus.Result() + return &HealthcheckResult{ + ExitCode: int(code), + Output: outputBuf.String(), + End: time.Now(), + }, nil + } +} + +// updateHealthStatus updates the health status based on the health check result +func updateHealthStatus(ctx context.Context, container containerd.Container, hcConfig *Healthcheck, hcResult *HealthcheckResult) error { + // Get current health state from labels + currentHealth, err := readHealthStateFromLabels(ctx, container) + if err != nil { + return fmt.Errorf("failed to read health state from labels: %w", err) + } + if currentHealth == nil { + // Determine if we should start in the start period workflow + hasStartPeriod := hcConfig.StartPeriod > 0 + currentHealth = &HealthState{ + Status: Starting, + FailingStreak: 0, + InStartPeriod: hasStartPeriod, + } + } + + // Get container info for start period check + info, err := container.Info(ctx) + if err != nil { + return fmt.Errorf("failed to get container info: %w", err) + } + containerCreated := info.CreatedAt + + // Check if we're in start period workflow + inStartPeriodTime := hcResult.Start.Sub(containerCreated) < hcConfig.StartPeriod + inStartPeriodState := currentHealth.InStartPeriod + + if inStartPeriodTime && inStartPeriodState { + // Start Period Workflow + if hcResult.ExitCode == 0 { + // First healthy result transitions us out of start period + currentHealth.Status = Healthy + currentHealth.FailingStreak = 0 + currentHealth.InStartPeriod = false + } + // Ignore unhealthy results during start period + } else { + // Health Interval Workflow + if hcResult.ExitCode == 0 { + if currentHealth.Status != Healthy { + currentHealth.Status = Healthy + currentHealth.FailingStreak = 0 + } + } else { + currentHealth.FailingStreak++ + if currentHealth.FailingStreak >= hcConfig.Retries && currentHealth.Status != Unhealthy { + currentHealth.Status = Unhealthy + } + } + } + + // Write updated health state back to labels + if err := writeHealthStateToLabels(ctx, container, currentHealth); err != nil { + return fmt.Errorf("failed to write health state to labels: %w", err) + } + + // Store the latest health check result in the log file + if err := writeHealthLog(ctx, container, hcResult); err != nil { + return fmt.Errorf("failed to write health log: %w", err) + } + return nil +} + +// prepareProcessSpec prepares the process spec for health check execution +func prepareProcessSpec(ctx context.Context, container containerd.Container, hcConfig *Healthcheck) (*specs.Process, error) { + hcCommand := hcConfig.Test + + var args []string + switch hcCommand[0] { + case TestNone, CmdNone: + log.G(ctx).Debug("health check is set to NONE, skipping execution") + return nil, nil + case Cmd: + args = hcCommand[1:] + case CmdShell: + if len(hcCommand) < 2 || strings.TrimSpace(hcCommand[1]) == "" { + return nil, fmt.Errorf("no health check command specified") + } + args = []string{"/bin/sh", "-c", strings.Join(hcCommand[1:], " ")} + default: + args = hcCommand + } + + if len(args) < 1 || args[0] == "" { + return nil, fmt.Errorf("no health check command specified") + } + + // Get container spec for environment and working directory + spec, err := container.Spec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get container spec: %w", err) + } + processSpec := &specs.Process{ + Args: args, + Env: spec.Process.Env, + User: spec.Process.User, + Cwd: spec.Process.Cwd, + } + + return processSpec, nil +} diff --git a/pkg/healthcheck/health.go b/pkg/healthcheck/health.go new file mode 100644 index 00000000000..70104187e29 --- /dev/null +++ b/pkg/healthcheck/health.go @@ -0,0 +1,154 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "encoding/json" + "time" +) + +type HealthStatus = string + +// Health states +const ( + NoHealthcheck HealthStatus = "none" // Indicates there is no healthcheck + Starting HealthStatus = "starting" + Healthy HealthStatus = "healthy" + Unhealthy HealthStatus = "unhealthy" +) + +// Healthcheck cmd types +const ( + CmdNone = "NONE" + Cmd = "CMD" + CmdShell = "CMD-SHELL" + TestNone = "" +) + +const ( + DefaultProbeInterval = 30 * time.Second // Default interval between probe runs. Also applies before the first probe. + DefaultProbeTimeout = 30 * time.Second // Max duration a single probe run may take before it's considered failed. + DefaultStartPeriod = 0 * time.Second // Grace period for container startup before health checks count as failures. + DefaultProbeRetries = 3 // Number of consecutive failures before marking container as unhealthy. + MaxLogEntries = 5 // Maximum number of health check log entries to keep. + MaxOutputLenForInspect = 4096 // Max output length (in bytes) stored in health check logs during inspect. Longer outputs are truncated. + MaxOutputLen = 1 * 1024 * 1024 // Max output size for health check logs: 1MB limit (prevents excessive memory usage) + HealthLogFilename = "health.json" // HealthLogFilename is the name of the file used to persist health check status for a container. +) + +// NOTE: Health, HealthcheckResult and Healthcheck types are kept Docker-compatible. +// See: https://github.com/moby/moby/blob/9d1b069a4bfdcee368e67767978eff596b696d4c/api/types/container/health.go +// Health stores information about the container's healthcheck results +type Health struct { + Status HealthStatus // Status is one of [Starting], [Healthy] or [Unhealthy]. + FailingStreak int // FailingStreak is the number of consecutive failures + Log []*HealthcheckResult // Log contains the last few results (oldest first) +} + +// HealthcheckResult stores information about a single run of a healthcheck probe +type HealthcheckResult struct { + Start time.Time // Start is the time this check started + End time.Time // End is the time this check ended + ExitCode int // ExitCode meanings: 0=healthy, 1=unhealthy, 2=reserved (considered unhealthy), else=error running probe + Output string // Output from last check +} + +// Healthcheck represents the health check configuration +type Healthcheck struct { + Test []string `json:"Test,omitempty"` // Test is the check to perform that the container is healthy + Interval time.Duration `json:"Interval,omitempty"` // Interval is the time to wait between checks + Timeout time.Duration `json:"Timeout,omitempty"` // Timeout is the time to wait before considering the check to have hung + Retries int `json:"Retries,omitempty"` // Retries is the number of consecutive failures needed to consider a container as unhealthy + StartPeriod time.Duration `json:"StartPeriod,omitempty"` // StartPeriod is the period for the container to initialize before the health check starts +} + +// HealthState stores the current health state of a container +type HealthState struct { + Status HealthStatus // Status is one of [Starting], [Healthy] or [Unhealthy] + FailingStreak int // FailingStreak is the number of consecutive failures + InStartPeriod bool // InStartPeriod indicates if we're in the start period workflow +} + +// ToJSONString serializes HealthState to a JSON string for label storage +func (hs *HealthState) ToJSONString() (string, error) { + b, err := json.Marshal(hs) + if err != nil { + return "", err + } + return string(b), nil +} + +// HealthStateFromJSON deserializes a JSON string into a HealthState +func HealthStateFromJSON(s string) (*HealthState, error) { + var hs HealthState + if err := json.Unmarshal([]byte(s), &hs); err != nil { + return nil, err + } + return &hs, nil +} + +// ToJSONString serializes a Healthcheck struct to a JSON string +func (hc *Healthcheck) ToJSONString() (string, error) { + b, err := json.Marshal(hc) + if err != nil { + return "", err + } + return string(b), nil +} + +// HealthCheckFromJSON deserializes a JSON string into a Healthcheck struct +func HealthCheckFromJSON(s string) (*Healthcheck, error) { + var hc Healthcheck + if err := json.Unmarshal([]byte(s), &hc); err != nil { + return nil, err + } + return &hc, nil +} + +// ToJSONString serializes a HealthcheckResult struct to a JSON string +func (r *HealthcheckResult) ToJSONString() (string, error) { + b, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(b), nil +} + +// HealthcheckResultFromJSON deserializes a JSON string into a HealthcheckResult struct +func HealthcheckResultFromJSON(s string) (*HealthcheckResult, error) { + var r HealthcheckResult + if err := json.Unmarshal([]byte(s), &r); err != nil { + return nil, err + } + return &r, nil +} + +// ApplyDefaults sets default values for unset healthcheck fields +func (hc *Healthcheck) ApplyDefaults() { + if hc.Interval == 0 { + hc.Interval = DefaultProbeInterval + } + if hc.Timeout == 0 { + hc.Timeout = DefaultProbeTimeout + } + if hc.StartPeriod == 0 { + hc.StartPeriod = DefaultStartPeriod + } + if hc.Retries == 0 { + hc.Retries = DefaultProbeRetries + } +} diff --git a/pkg/healthcheck/healthcheck_manager_darwin.go b/pkg/healthcheck/healthcheck_manager_darwin.go new file mode 100644 index 00000000000..289d0c16704 --- /dev/null +++ b/pkg/healthcheck/healthcheck_manager_darwin.go @@ -0,0 +1,47 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/config" +) + +// CreateTimer sets up the transient systemd timer and service for healthchecks. +func CreateTimer(ctx context.Context, container containerd.Container, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) error { + return nil +} + +// StartTimer starts the healthcheck timer unit. +func StartTimer(ctx context.Context, container containerd.Container, cfg *config.Config) error { + return nil +} + +// RemoveTransientHealthCheckFiles stops and cleans up the transient timer and service. +func RemoveTransientHealthCheckFiles(ctx context.Context, container containerd.Container) error { + return nil +} + +// ForceRemoveTransientHealthCheckFiles forcefully stops and cleans up the transient timer and service +// using just the container ID. This function is non-blocking and uses timeouts to prevent hanging +// on systemd operations. On Darwin, this is a no-op since systemd is not available. +func ForceRemoveTransientHealthCheckFiles(ctx context.Context, containerID string) error { + return nil +} diff --git a/pkg/healthcheck/healthcheck_manager_freebsd.go b/pkg/healthcheck/healthcheck_manager_freebsd.go new file mode 100644 index 00000000000..289d0c16704 --- /dev/null +++ b/pkg/healthcheck/healthcheck_manager_freebsd.go @@ -0,0 +1,47 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/config" +) + +// CreateTimer sets up the transient systemd timer and service for healthchecks. +func CreateTimer(ctx context.Context, container containerd.Container, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) error { + return nil +} + +// StartTimer starts the healthcheck timer unit. +func StartTimer(ctx context.Context, container containerd.Container, cfg *config.Config) error { + return nil +} + +// RemoveTransientHealthCheckFiles stops and cleans up the transient timer and service. +func RemoveTransientHealthCheckFiles(ctx context.Context, container containerd.Container) error { + return nil +} + +// ForceRemoveTransientHealthCheckFiles forcefully stops and cleans up the transient timer and service +// using just the container ID. This function is non-blocking and uses timeouts to prevent hanging +// on systemd operations. On Darwin, this is a no-op since systemd is not available. +func ForceRemoveTransientHealthCheckFiles(ctx context.Context, containerID string) error { + return nil +} diff --git a/pkg/healthcheck/healthcheck_manager_linux.go b/pkg/healthcheck/healthcheck_manager_linux.go new file mode 100644 index 00000000000..92b49bd0cc4 --- /dev/null +++ b/pkg/healthcheck/healthcheck_manager_linux.go @@ -0,0 +1,273 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/coreos/go-systemd/v22/dbus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/config" + "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) + +// CreateTimer sets up the transient systemd timer and service for healthchecks. +func CreateTimer(ctx context.Context, container containerd.Container, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) error { + hc := extractHealthcheck(ctx, container) + if hc == nil { + return nil + } + if shouldSkipHealthCheckSystemd(hc, cfg) { + return nil + } + + containerID := container.ID() + log.G(ctx).Debugf("Creating healthcheck timer unit: %s", containerID) + + // Set all environment variables so that they are available for the nerdctl commands run via the systemd service file + cmdOpts := []string{} + if path := os.Getenv("PATH"); path != "" { + cmdOpts = append(cmdOpts, "--setenv=PATH="+path) + } + + if nerdctlToml := os.Getenv("NERDCTL_TOML"); nerdctlToml != "" { + cmdOpts = append(cmdOpts, "--setenv=NERDCTL_TOML="+nerdctlToml) + } + + if buildKitHost := os.Getenv("BUILDKIT_HOST"); buildKitHost != "" { + cmdOpts = append(cmdOpts, "--setenv=BUILDKIT_HOST="+buildKitHost) + } + + // Always use health-interval for timer frequency + cmdOpts = append(cmdOpts, "--unit", containerID, "--on-unit-inactive="+hc.Interval.String(), "--timer-property=AccuracySec=1s") + + cmdOpts = append(cmdOpts, nerdctlCmd) + cmdOpts = append(cmdOpts, nerdctlArgs...) + cmdOpts = append(cmdOpts, "container", "healthcheck", containerID) + + log.G(ctx).Debugf("creating healthcheck timer with: systemd-run %s", strings.Join(cmdOpts, " ")) + run := exec.Command("systemd-run", cmdOpts...) + if out, err := run.CombinedOutput(); err != nil { + return fmt.Errorf("systemd-run failed: %w\noutput: %s", err, strings.TrimSpace(string(out))) + } + + return nil +} + +// StartTimer starts the healthcheck timer unit. +func StartTimer(ctx context.Context, container containerd.Container, cfg *config.Config) error { + hc := extractHealthcheck(ctx, container) + if hc == nil { + return nil + } + if shouldSkipHealthCheckSystemd(hc, cfg) { + return nil + } + + containerID := container.ID() + var conn *dbus.Conn + var err error + if rootlessutil.IsRootless() { + conn, err = dbus.NewUserConnectionContext(ctx) + } else { + conn, err = dbus.NewSystemConnectionContext(ctx) + } + if err != nil { + return fmt.Errorf("systemd DBUS connect error: %w", err) + } + defer conn.Close() + + startChan := make(chan string) + unit := containerID + ".service" + if _, err := conn.RestartUnitContext(context.Background(), unit, "fail", startChan); err != nil { + return err + } + if msg := <-startChan; msg != "done" { + return fmt.Errorf("unexpected systemd restart result: %s", msg) + } + return nil +} + +// RemoveTransientHealthCheckFiles stops and cleans up the transient timer and service. +func RemoveTransientHealthCheckFiles(ctx context.Context, container containerd.Container) error { + hc := extractHealthcheck(ctx, container) + if hc == nil { + return nil + } + + return ForceRemoveTransientHealthCheckFiles(ctx, container.ID()) +} + +// ForceRemoveTransientHealthCheckFiles forcefully stops and cleans up the transient timer and service +// using just the container ID. This function is non-blocking and uses timeouts to prevent hanging +// on systemd operations. It logs errors as warnings but continues cleanup attempts. +func ForceRemoveTransientHealthCheckFiles(ctx context.Context, containerID string) error { + log.G(ctx).Debugf("Force removing healthcheck timer unit: %s", containerID) + + // Create a timeout context for systemd operations + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + timer := containerID + ".timer" + service := containerID + ".service" + + // Channel to collect any critical errors (though we'll continue cleanup regardless) + errChan := make(chan error, 3) + + // Goroutine for DBUS connection and cleanup operations + go func() { + defer close(errChan) + + var conn *dbus.Conn + var err error + if rootlessutil.IsRootless() { + conn, err = dbus.NewUserConnectionContext(ctx) + } else { + conn, err = dbus.NewSystemConnectionContext(ctx) + } + if err != nil { + log.G(ctx).Warnf("systemd DBUS connect error during force cleanup: %v", err) + errChan <- fmt.Errorf("systemd DBUS connect error: %w", err) + return + } + defer conn.Close() + + // Stop timer with timeout + go func() { + select { + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("timeout stopping timer %s during force cleanup", timer) + return + default: + tChan := make(chan string, 1) + if _, err := conn.StopUnitContext(timeoutCtx, timer, "ignore-dependencies", tChan); err == nil { + select { + case msg := <-tChan: + if msg != "done" { + log.G(ctx).Warnf("timer stop message during force cleanup: %s", msg) + } + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("timeout waiting for timer stop confirmation: %s", timer) + } + } else { + log.G(ctx).Warnf("failed to stop timer %s during force cleanup: %v", timer, err) + } + } + }() + + // Stop service with timeout + go func() { + select { + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("timeout stopping service %s during force cleanup", service) + return + default: + sChan := make(chan string, 1) + if _, err := conn.StopUnitContext(timeoutCtx, service, "ignore-dependencies", sChan); err == nil { + select { + case msg := <-sChan: + if msg != "done" { + log.G(ctx).Warnf("service stop message during force cleanup: %s", msg) + } + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("timeout waiting for service stop confirmation: %s", service) + } + } else { + log.G(ctx).Warnf("failed to stop service %s during force cleanup: %v", service, err) + } + } + }() + + // Reset failed units (best effort, non-blocking) + go func() { + select { + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("timeout resetting failed unit %s during force cleanup", service) + return + default: + if err := conn.ResetFailedUnitContext(timeoutCtx, service); err != nil { + log.G(ctx).Warnf("failed to reset failed unit %s during force cleanup: %v", service, err) + } + } + }() + + // Wait a short time for operations to complete, but don't block indefinitely + select { + case <-time.After(3 * time.Second): + log.G(ctx).Debugf("force cleanup operations completed for container %s", containerID) + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("force cleanup timed out for container %s", containerID) + } + }() + + // Wait for the cleanup goroutine to finish or timeout + select { + case err := <-errChan: + if err != nil { + log.G(ctx).Warnf("force cleanup encountered errors but continuing: %v", err) + } + case <-timeoutCtx.Done(): + log.G(ctx).Warnf("force cleanup timed out for container %s, but cleanup may continue in background", containerID) + } + + // Always return nil - this function should never block the caller + // even if systemd operations fail or timeout + log.G(ctx).Debugf("force cleanup completed (non-blocking) for container %s", containerID) + return nil +} + +func extractHealthcheck(ctx context.Context, container containerd.Container) *Healthcheck { + l, err := container.Labels(ctx) + if err != nil { + log.G(ctx).WithError(err).Debugf("could not get labels for container %s", container.ID()) + return nil + } + hcStr, ok := l[labels.HealthCheck] + if !ok || hcStr == "" { + return nil + } + hc, err := HealthCheckFromJSON(hcStr) + if err != nil { + log.G(ctx).WithError(err).Debugf("invalid healthcheck config on container %s", container.ID()) + return nil + } + return hc +} + +// shouldSkipHealthCheckSystemd determines if healthcheck timers should be skipped. +func shouldSkipHealthCheckSystemd(hc *Healthcheck, cfg *config.Config) bool { + // Don't proceed if systemd is unavailable or disabled + if !defaults.IsSystemdAvailable() || cfg.DisableHCSystemd || rootlessutil.IsRootless() { + return true + } + + // Don't proceed if health check is nil, empty or explicitly NONE. + if hc == nil || len(hc.Test) == 0 || hc.Test[0] == "NONE" { + return true + } + return false +} diff --git a/pkg/healthcheck/healthcheck_manager_windows.go b/pkg/healthcheck/healthcheck_manager_windows.go new file mode 100644 index 00000000000..e5fa58a4a08 --- /dev/null +++ b/pkg/healthcheck/healthcheck_manager_windows.go @@ -0,0 +1,47 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/config" +) + +// CreateTimer sets up the transient systemd timer and service for healthchecks. +func CreateTimer(ctx context.Context, container containerd.Container, cfg *config.Config, nerdctlCmd string, nerdctlArgs []string) error { + return nil +} + +// StartTimer starts the healthcheck timer unit. +func StartTimer(ctx context.Context, container containerd.Container, cfg *config.Config) error { + return nil +} + +// RemoveTransientHealthCheckFiles stops and cleans up the transient timer and service. +func RemoveTransientHealthCheckFiles(ctx context.Context, container containerd.Container) error { + return nil +} + +// ForceRemoveTransientHealthCheckFiles forcefully stops and cleans up the transient timer and service +// using just the container ID. This function is non-blocking and uses timeouts to prevent hanging +// on systemd operations. On Windows, this is a no-op since systemd is not available. +func ForceRemoveTransientHealthCheckFiles(ctx context.Context, containerID string) error { + return nil +} diff --git a/pkg/healthcheck/log.go b/pkg/healthcheck/log.go new file mode 100644 index 00000000000..9695acc7002 --- /dev/null +++ b/pkg/healthcheck/log.go @@ -0,0 +1,239 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package healthcheck + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" + "github.com/containerd/nerdctl/v2/pkg/labels" +) + +// writeHealthLog writes the latest health check result to the log file, appending it to existing logs. +func writeHealthLog(ctx context.Context, container containerd.Container, result *HealthcheckResult) error { + stateDir, err := getContainerStateDir(ctx, container) + if err != nil { + return fmt.Errorf("error fetching container state dir: %v", err) + } + + data, err := result.ToJSONString() + if err != nil { + return fmt.Errorf("failed to marshal health log: %w", err) + } + + // Write the latest result to the file + logPath := filepath.Join(stateDir, HealthLogFilename) + return filesystem.WithLock(stateDir, func() error { + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer file.Close() + if _, err = file.Seek(0, io.SeekEnd); err != nil { + return fmt.Errorf("seek error: %w", err) + } + if _, err = file.Write(append([]byte(data), '\n')); err != nil { + return fmt.Errorf("failed to write health log: %w", err) + } + + return file.Sync() + }) +} + +// ReadHealthStatusForInspect reads the health state from labels and the last MaxLogEntries health check result logs. +func ReadHealthStatusForInspect(stateDir, healthState string) (*Health, error) { + state, err := HealthStateFromJSON(healthState) + if err != nil { + return nil, fmt.Errorf("failed to parse health state: %w", err) + } + + logPath := filepath.Join(stateDir, HealthLogFilename) + var logs []*HealthcheckResult + err = filesystem.WithReadOnlyLock(logPath, func() error { + file, err := os.Open(logPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + reader := bufio.NewReader(file) + for { + line, err := reader.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + line = strings.TrimRight(line, "\n") + result, err := HealthcheckResultFromJSON(line) + if err != nil { + log.L.Warnf("failed to parse healthcheck log line: %v", err) + continue + } + logs = append(logs, result) + } + return nil + }) + if err != nil { + return nil, err + } + + // Keep only the last MaxLogEntries + n := len(logs) + if n > MaxLogEntries { + logs = logs[n-MaxLogEntries:] + } + + // Reverse for newest-first order + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + + // Truncate log outputs to avoid flooding inspect output + for _, logEntry := range logs { + if len(logEntry.Output) > MaxOutputLenForInspect { + buf := NewResizableBuffer(MaxOutputLenForInspect) + _, _ = buf.Write([]byte(logEntry.Output)) + logEntry.Output = buf.String() + } + } + + // Create a Health object with the health state and logs + health := &Health{ + Status: state.Status, + FailingStreak: state.FailingStreak, + Log: logs, + } + + return health, nil +} + +// writeHealthStateToLabels writes the health state to container labels +func writeHealthStateToLabels(ctx context.Context, container containerd.Container, healthState *HealthState) error { + hs, err := healthState.ToJSONString() + if err != nil { + return fmt.Errorf("failed to marshal health healthState: %w", err) + } + + lbs, err := container.Labels(ctx) + if err != nil { + return fmt.Errorf("failed to get container labels: %w", err) + } + + // Update healthState label + lbs[labels.HealthState] = hs + _, err = container.SetLabels(ctx, lbs) + if err != nil { + return fmt.Errorf("failed to update container labels: %w", err) + } + + return nil +} + +// readHealthStateFromLabels reads the health state from container labels +func readHealthStateFromLabels(ctx context.Context, container containerd.Container) (*HealthState, error) { + lbs, err := container.Labels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get container labels: %w", err) + } + + // Check if health state label exists + stateJSON, ok := lbs[labels.HealthState] + if !ok { + return nil, nil + } + + // HealthCheckFromJSON health state from JSON + state, err := HealthStateFromJSON(stateJSON) + if err != nil { + return nil, fmt.Errorf("failed to parse health state: %w", err) + } + + return state, nil +} + +// getContainerStateDir returns the container's state directory from labels. +func getContainerStateDir(ctx context.Context, container containerd.Container) (string, error) { + info, err := container.Info(ctx) + if err != nil { + return "", err + } + stateDir, ok := info.Labels[labels.StateDir] + if !ok { + return "", err + } + return stateDir, nil +} + +// ResizableBuffer collects output with a configurable upper limit. +type ResizableBuffer struct { + mu sync.Mutex + buf bytes.Buffer + maxSize int + truncated bool +} + +// NewResizableBuffer returns a new buffer with the given size limit in bytes. +func NewResizableBuffer(maxSize int) *ResizableBuffer { + return &ResizableBuffer{maxSize: maxSize} +} + +func (b *ResizableBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + remaining := b.maxSize - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return len(p), nil + } + + if len(p) > remaining { + b.truncated = true + p = p[:remaining] + } + + return b.buf.Write(p) +} + +func (b *ResizableBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + s := b.buf.String() + if b.truncated { + s += "... [truncated]" + } + return s +} diff --git a/pkg/imgutil/commit/commit.go b/pkg/imgutil/commit/commit.go index fd5886bfb7f..f283a4dd290 100644 --- a/pkg/imgutil/commit/commit.go +++ b/pkg/imgutil/commit/commit.go @@ -27,6 +27,7 @@ import ( "strings" "time" + "github.com/klauspost/compress/zstd" "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/identity" "github.com/opencontainers/image-spec/specs-go" @@ -43,6 +44,9 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/platforms" + "github.com/containerd/stargz-snapshotter/estargz" + estargzconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz" + zstdchunkedconvert "github.com/containerd/stargz-snapshotter/nativeconverter/zstdchunked" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" @@ -57,11 +61,15 @@ type Changes struct { } type Opts struct { - Author string - Message string - Ref string - Pause bool - Changes Changes + Author string + Message string + Ref string + Pause bool + Changes Changes + Compression types.CompressionType + Format types.ImageFormat + types.EstargzOptions + types.ZstdChunkedOptions } var ( @@ -176,7 +184,10 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd // Sync filesystem to make sure that all the data writes in container could be persisted to disk. Sync() - diffLayerDesc, diffID, err := createDiff(ctx, id, sn, client.ContentStore(), differ) + if opts.ZstdChunked { + opts.Compression = types.Zstd + } + diffLayerDesc, diffID, err := createDiff(ctx, id, sn, client.ContentStore(), differ, opts.Compression, opts) if err != nil { return emptyDigest, fmt.Errorf("failed to export layer: %w", err) } @@ -191,7 +202,7 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd return emptyDigest, fmt.Errorf("failed to apply diff: %w", err) } - commitManifestDesc, configDigest, err := writeContentsForImage(ctx, snName, baseImg, imageConfig, diffLayerDesc) + commitManifestDesc, configDigest, err := writeContentsForImage(ctx, snName, baseImg, imageConfig, diffLayerDesc, opts) if err != nil { return emptyDigest, err } @@ -286,14 +297,29 @@ func generateCommitImageConfig(ctx context.Context, container containerd.Contain } // writeContentsForImage will commit oci image config and manifest into containerd's content store. -func writeContentsForImage(ctx context.Context, snName string, baseImg containerd.Image, newConfig ocispec.Image, diffLayerDesc ocispec.Descriptor) (ocispec.Descriptor, digest.Digest, error) { +func writeContentsForImage(ctx context.Context, snName string, baseImg containerd.Image, newConfig ocispec.Image, diffLayerDesc ocispec.Descriptor, opts *Opts) (ocispec.Descriptor, digest.Digest, error) { newConfigJSON, err := json.Marshal(newConfig) if err != nil { return ocispec.Descriptor{}, emptyDigest, err } + // Select media types based on format choice + var configMediaType, manifestMediaType string + switch opts.Format { + case types.ImageFormatOCI: + configMediaType = ocispec.MediaTypeImageConfig + manifestMediaType = ocispec.MediaTypeImageManifest + case types.ImageFormatDocker: + configMediaType = images.MediaTypeDockerSchema2Config + manifestMediaType = images.MediaTypeDockerSchema2Manifest + default: + // Default to Docker Schema2 for compatibility + configMediaType = images.MediaTypeDockerSchema2Config + manifestMediaType = images.MediaTypeDockerSchema2Manifest + } + configDesc := ocispec.Descriptor{ - MediaType: images.MediaTypeDockerSchema2Config, + MediaType: configMediaType, Digest: digest.FromBytes(newConfigJSON), Size: int64(len(newConfigJSON)), } @@ -309,7 +335,7 @@ func writeContentsForImage(ctx context.Context, snName string, baseImg container MediaType string `json:"mediaType,omitempty"` ocispec.Manifest }{ - MediaType: images.MediaTypeDockerSchema2Manifest, + MediaType: manifestMediaType, Manifest: ocispec.Manifest{ Versioned: specs.Versioned{ SchemaVersion: 2, @@ -325,7 +351,7 @@ func writeContentsForImage(ctx context.Context, snName string, baseImg container } newMfstDesc := ocispec.Descriptor{ - MediaType: images.MediaTypeDockerSchema2Manifest, + MediaType: manifestMediaType, Digest: digest.FromBytes(newMfstJSON), Size: int64(len(newMfstJSON)), } @@ -356,8 +382,45 @@ func writeContentsForImage(ctx context.Context, snName string, baseImg container } // createDiff creates a layer diff into containerd's content store. -func createDiff(ctx context.Context, name string, sn snapshots.Snapshotter, cs content.Store, comparer diff.Comparer) (ocispec.Descriptor, digest.Digest, error) { - newDesc, err := rootfs.CreateDiff(ctx, name, sn, comparer) +func createDiff(ctx context.Context, name string, sn snapshots.Snapshotter, cs content.Store, comparer diff.Comparer, compression types.CompressionType, opts *Opts) (ocispec.Descriptor, digest.Digest, error) { + diffOpts := make([]diff.Opt, 0) + var mediaType string + + // Select media type based on format and compression + switch opts.Format { + case types.ImageFormatOCI: + // Use OCI media types + switch compression { + case types.Zstd: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerZstd)) + mediaType = ocispec.MediaTypeImageLayerZstd + default: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerGzip)) + mediaType = ocispec.MediaTypeImageLayerGzip + } + case types.ImageFormatDocker: + // Use Docker Schema2 media types for compatibility + switch compression { + case types.Zstd: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerZstd)) + mediaType = images.MediaTypeDockerSchema2LayerZstd + default: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerGzip)) + mediaType = images.MediaTypeDockerSchema2LayerGzip + } + default: + // Default to Docker Schema2 media types for compatibility + switch compression { + case types.Zstd: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerZstd)) + mediaType = images.MediaTypeDockerSchema2LayerZstd + default: + diffOpts = append(diffOpts, diff.WithMediaType(ocispec.MediaTypeImageLayerGzip)) + mediaType = images.MediaTypeDockerSchema2LayerGzip + } + } + + newDesc, err := rootfs.CreateDiff(ctx, name, sn, comparer, diffOpts...) if err != nil { return ocispec.Descriptor{}, digest.Digest(""), err } @@ -377,8 +440,90 @@ func createDiff(ctx context.Context, name string, sn snapshots.Snapshotter, cs c return ocispec.Descriptor{}, digest.Digest(""), err } + // Convert to eStargz if requested + if opts.Estargz { + log.G(ctx).Infof("Converting diff layer to eStargz format") + + esgzOpts := []estargz.Option{ + estargz.WithCompressionLevel(opts.EstargzCompressionLevel), + } + if opts.EstargzChunkSize > 0 { + esgzOpts = append(esgzOpts, estargz.WithChunkSize(opts.EstargzChunkSize)) + } + if opts.EstargzMinChunkSize > 0 { + esgzOpts = append(esgzOpts, estargz.WithMinChunkSize(opts.EstargzMinChunkSize)) + } + + convertFunc := estargzconvert.LayerConvertFunc(esgzOpts...) + + esgzDesc, err := convertFunc(ctx, cs, newDesc) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), fmt.Errorf("failed to convert diff layer to eStargz: %w", err) + } else if esgzDesc != nil { + esgzDesc.MediaType = mediaType + esgzInfo, err := cs.Info(ctx, esgzDesc.Digest) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + + esgzDiffIDStr, ok := esgzInfo.Labels["containerd.io/uncompressed"] + if !ok { + return ocispec.Descriptor{}, digest.Digest(""), fmt.Errorf("invalid differ response with no diffID") + } + + esgzDiffID, err := digest.Parse(esgzDiffIDStr) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + return ocispec.Descriptor{ + MediaType: esgzDesc.MediaType, + Digest: esgzDesc.Digest, + Size: esgzDesc.Size, + Annotations: esgzDesc.Annotations, + }, esgzDiffID, nil + } + } + + // Convert to zstd:chunked if requested + if opts.ZstdChunked { + log.G(ctx).Infof("Converting diff layer to zstd:chunked format") + + esgzOpts := []estargz.Option{ + estargz.WithChunkSize(opts.ZstdChunkedChunkSize), + } + + convertFunc := zstdchunkedconvert.LayerConvertFuncWithCompressionLevel(zstd.EncoderLevelFromZstd(opts.ZstdChunkedCompressionLevel), esgzOpts...) + + zstdchunkedDesc, err := convertFunc(ctx, cs, newDesc) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), fmt.Errorf("failed to convert diff layer to zstd:chunked: %w", err) + } else if zstdchunkedDesc != nil { + zstdchunkedDesc.MediaType = mediaType + zstdchunkedInfo, err := cs.Info(ctx, zstdchunkedDesc.Digest) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + + zstdchunkedDiffIDStr, ok := zstdchunkedInfo.Labels["containerd.io/uncompressed"] + if !ok { + return ocispec.Descriptor{}, digest.Digest(""), fmt.Errorf("invalid differ response with no diffID") + } + + zstdchunkedDiffID, err := digest.Parse(zstdchunkedDiffIDStr) + if err != nil { + return ocispec.Descriptor{}, digest.Digest(""), err + } + return ocispec.Descriptor{ + MediaType: zstdchunkedDesc.MediaType, + Digest: zstdchunkedDesc.Digest, + Size: zstdchunkedDesc.Size, + Annotations: zstdchunkedDesc.Annotations, + }, zstdchunkedDiffID, nil + } + } + return ocispec.Descriptor{ - MediaType: images.MediaTypeDockerSchema2LayerGzip, + MediaType: mediaType, Digest: newDesc.Digest, Size: info.Size, }, diffID, nil diff --git a/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go b/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go index 61b99d87d8d..5dedacc82c2 100644 --- a/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go +++ b/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go @@ -26,6 +26,7 @@ import ( "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) @@ -92,7 +93,7 @@ func TestBrokenCredentialsStore(t *testing.T) { description: "Pointing DOCKER_CONFIG at a directory containing am unparsable `config.json` will prevent instantiation", setup: func() string { tmpDir := createTempDir(t, 0700) - err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("porked"), 0600) + err := filesystem.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("porked"), 0600) if err != nil { t.Fatal(err) } @@ -143,7 +144,7 @@ func TestBrokenCredentialsStore(t *testing.T) { description: "Pointing DOCKER_CONFIG at a directory containing an unreadable, valid `config.json` file will prevent instantiation", setup: func() string { tmpDir := createTempDir(t, 0700) - err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) + err := filesystem.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) if err != nil { t.Fatal(err) } @@ -159,7 +160,7 @@ func TestBrokenCredentialsStore(t *testing.T) { description: "Pointing DOCKER_CONFIG at a directory containing a read-only, valid `config.json` file will NOT prevent saving credentials", setup: func() string { tmpDir := createTempDir(t, 0700) - err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) + err := filesystem.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) if err != nil { t.Fatal(err) } @@ -215,7 +216,7 @@ func TestBrokenCredentialsStore(t *testing.T) { func writeContent(t *testing.T, content string) string { t.Helper() tmpDir := createTempDir(t, 0700) - err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte(content), 0600) + err := filesystem.WriteFile(filepath.Join(tmpDir, "config.json"), []byte(content), 0600) if err != nil { t.Fatal(err) } diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go index 8577b8e2bc6..3397df877ca 100644 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go +++ b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go @@ -20,10 +20,16 @@ import ( "context" "crypto/tls" "errors" + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/containerd/v2/core/transfer/registry" "github.com/containerd/errdefs" "github.com/containerd/log" ) @@ -193,3 +199,70 @@ func NewAuthCreds(refHostname string) (AuthCreds, error) { return credFunc, nil } + +func NewCredentialHelper(refHostname string) (registry.CredentialHelper, error) { + authCreds, err := NewAuthCreds(refHostname) + if err != nil { + return nil, err + } + return &credentialHelper{authCreds: authCreds}, nil +} + +type credentialHelper struct { + authCreds AuthCreds +} + +func (ch *credentialHelper) GetCredentials(ctx context.Context, ref, host string) (registry.Credentials, error) { + username, secret, err := ch.authCreds(host) + if err != nil { + return registry.Credentials{}, err + } + return registry.Credentials{ + Host: host, + Username: username, + Secret: secret, + }, nil +} + +type hostFileConfig struct { + SkipVerify *bool `toml:"skip_verify,omitempty"` +} + +// CreateTmpHostsConfig creates a temporary hosts directory with hosts.toml configured for skip_verify +// Returns the temporary directory path or empty string if creation failed +func CreateTmpHostsConfig(hostname string, skipVerify bool) (string, error) { + if !skipVerify { + return "", nil + } + + tempDir, err := os.MkdirTemp("", "nerdctl-hosts-*") + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + hostDir := filepath.Join(tempDir, hostname) + if err := os.MkdirAll(hostDir, 0755); err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to create host directory: %w", err) + } + + config := hostFileConfig{} + if skipVerify { + skip := true + config.SkipVerify = &skip + } + + data, err := toml.Marshal(config) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to marshal hosts config: %w", err) + } + + hostsTomlPath := filepath.Join(hostDir, "hosts.toml") + if err := os.WriteFile(hostsTomlPath, data, 0644); err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to write hosts.toml: %w", err) + } + + return tempDir, nil +} diff --git a/pkg/imgutil/imgutil.go b/pkg/imgutil/imgutil.go index 3f8076df9f4..227f1dbd37b 100644 --- a/pkg/imgutil/imgutil.go +++ b/pkg/imgutil/imgutil.go @@ -39,10 +39,13 @@ import ( "github.com/containerd/platforms" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" "github.com/containerd/nerdctl/v2/pkg/imgutil/pull" + "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) @@ -131,6 +134,17 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, return nil, err } + // Transfer service is available in containerd 1.7, but full support is only in 2.0+ + // For containerd 1.7, use the legacy resolver-based pull method for better compatibility + useTransferAPI := containerdutil.SupportsFullTransferService(ctx, client) + if !useTransferAPI { + log.G(ctx).Debug("Detected containerd < 2.0, using legacy pull method") + } + + if useTransferAPI { + return PullImageWithTransfer(ctx, client, parsedReference, rawRef, options) + } + var dOpts []dockerconfigresolver.Opt if options.GOptions.InsecureRegistry { log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", parsedReference.Domain) @@ -271,6 +285,10 @@ func getImageConfig(ctx context.Context, image containerd.Image) (*ocispec.Image if err := json.Unmarshal(b, &ocispecImage); err != nil { return nil, err } + + if err := addHealthCheckToImageConfig(b, &ocispecImage.Config); err != nil { + log.G(ctx).WithError(err).Debug("failed to add health check config") + } return &ocispecImage.Config, nil default: return nil, fmt.Errorf("unknown media type %q", desc.MediaType) @@ -354,6 +372,9 @@ func ReadImageConfig(ctx context.Context, img containerd.Image) (ocispec.Image, if err := json.Unmarshal(p, &config); err != nil { return config, configDesc, err } + if err := addHealthCheckToImageConfig(p, &config.Config); err != nil { + log.G(ctx).WithError(err).Debug("failed to add health check config") + } return config, configDesc, nil } @@ -464,3 +485,28 @@ func GetDanglingImages(ctx context.Context, client *containerd.Client, filters . return ApplyFilters(allImages, filters...) } + +// addHealthCheckToImageConfig extracts health check information from the image content store and adds it to the labels +func addHealthCheckToImageConfig(rawConfigContent []byte, config *ocispec.ImageConfig) error { + var imgConfig struct { + Config struct { + Healthcheck *healthcheck.Healthcheck `json:"Healthcheck,omitempty"` + } `json:"config"` + } + + if err := json.Unmarshal(rawConfigContent, &imgConfig); err != nil { + return err + } + + if imgConfig.Config.Healthcheck != nil { + healthCheckJSON, err := json.Marshal(imgConfig.Config.Healthcheck) + if err != nil { + return err + } + if config.Labels == nil { + config.Labels = make(map[string]string) + } + config.Labels[labels.HealthCheck] = string(healthCheckJSON) + } + return nil +} diff --git a/pkg/imgutil/load/load.go b/pkg/imgutil/load/load.go index 0afb322f4e4..5e80096d5db 100644 --- a/pkg/imgutil/load/load.go +++ b/pkg/imgutil/load/load.go @@ -20,19 +20,19 @@ import ( "context" "errors" "fmt" - "io" "os" "strings" containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/images" - "github.com/containerd/containerd/v2/core/images/archive" - "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/containerd/v2/core/transfer" + tarchive "github.com/containerd/containerd/v2/core/transfer/archive" + transferimage "github.com/containerd/containerd/v2/core/transfer/image" "github.com/containerd/platforms" "github.com/containerd/nerdctl/v2/pkg/api/types" - "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/transferutil" ) // FromArchive loads and unpacks the images from the tar archive specified in image load options. @@ -54,27 +54,59 @@ func FromArchive(ctx context.Context, client *containerd.Client, options types.I return nil, errors.New("stdin is empty and input flag is not specified") } } - decompressor, err := compression.DecompressStream(options.Stdin) - if err != nil { + + if _, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform); err != nil { return nil, err } - platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) + + imageService := client.ImageService() + beforeImages, err := imageService.List(ctx) if err != nil { return nil, err } - imgs, err := importImages(ctx, client, decompressor, options.GOptions.Snapshotter, platMC) - if err != nil { - return nil, err + beforeSet := make(map[string]bool) + for _, img := range beforeImages { + beforeSet[img.Name] = true } - unpackedImages := make([]images.Image, 0, len(imgs)) - for _, img := range imgs { - err := unpackImage(ctx, client, img, platMC, options) + + var storeOpts []transferimage.StoreOpt + platUnpack := platforms.DefaultSpec() + if len(options.Platform) > 0 { + p, err := platforms.Parse(options.Platform[0]) if err != nil { - return unpackedImages, fmt.Errorf("error unpacking image (%s): %w", img.Name, err) + return nil, fmt.Errorf("invalid platform %q: %w", options.Platform[0], err) } - unpackedImages = append(unpackedImages, img) + platUnpack = p + storeOpts = append(storeOpts, transferimage.WithPlatforms(p)) + } else if !options.AllPlatforms { + storeOpts = append(storeOpts, transferimage.WithPlatforms(platUnpack)) } - return unpackedImages, nil + storeOpts = append(storeOpts, transferimage.WithUnpack(platUnpack, options.GOptions.Snapshotter)) + storeOpts = append(storeOpts, transferimage.WithDigestRef("import", true, true)) + + var loadedImages []images.Image + pf, done := transferutil.ProgressHandler(ctx, options.Stdout) + defer done() + + err = client.Transfer(ctx, + tarchive.NewImageImportStream(options.Stdin, ""), + transferimage.NewStore("", storeOpts...), + transfer.WithProgress(func(p transfer.Progress) { + if p.Event == "saved" { + if img, err := imageService.Get(ctx, p.Name); err == nil { + if !beforeSet[img.Name] { + loadedImages = append(loadedImages, img) + if !options.Quiet { + fmt.Fprintf(options.Stdout, "Loaded image: %s\n", img.Name) + } + } + } + } + pf(p) + }), + ) + + return loadedImages, err } // FromOCIArchive loads and unpacks the images from the OCI formatted archive at the provided file system path. @@ -95,57 +127,3 @@ func FromOCIArchive(ctx context.Context, client *containerd.Client, pathToOCIArc return FromArchive(ctx, client, options) } - -type readCounter struct { - io.Reader - N int -} - -func (r *readCounter) Read(p []byte) (int, error) { - n, err := r.Reader.Read(p) - if n > 0 { - r.N += n - } - return n, err -} - -func importImages(ctx context.Context, client *containerd.Client, in io.Reader, snapshotter string, platformMC platforms.MatchComparer) ([]images.Image, error) { - // In addition to passing WithImagePlatform() to client.Import(), we also need to pass WithDefaultPlatform() to NewClient(). - // Otherwise unpacking may fail. - r := &readCounter{Reader: in} - imgs, err := client.Import(ctx, r, - containerd.WithDigestRef(archive.DigestTranslator(snapshotter)), - containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), - containerd.WithImportPlatform(platformMC), - ) - if err != nil { - if r.N == 0 { - // Avoid confusing "unrecognized image format" - return nil, errors.New("no image was built") - } - if errors.Is(err, images.ErrEmptyWalk) { - err = fmt.Errorf("%w (Hint: set `--platform=PLATFORM` or `--all-platforms`)", err) - } - return nil, err - } - return imgs, nil -} - -func unpackImage(ctx context.Context, client *containerd.Client, model images.Image, platform platforms.MatchComparer, options types.ImageLoadOptions) error { - image := containerd.NewImageWithPlatform(client, model, platform) - - if !options.Quiet { - fmt.Fprintf(options.Stdout, "unpacking %s (%s)...\n", model.Name, model.Target.Digest) - } - - err := image.Unpack(ctx, options.GOptions.Snapshotter) - if err != nil { - return err - } - - // Loaded message is shown even when quiet. - repo, tag := imgutil.ParseRepoTag(model.Name) - fmt.Fprintf(options.Stdout, "Loaded image: %s:%s\n", repo, tag) - - return nil -} diff --git a/pkg/imgutil/transfer.go b/pkg/imgutil/transfer.go new file mode 100644 index 00000000000..532f9982aee --- /dev/null +++ b/pkg/imgutil/transfer.go @@ -0,0 +1,232 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package imgutil + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/remotes/docker" + "github.com/containerd/containerd/v2/core/transfer" + transferimage "github.com/containerd/containerd/v2/core/transfer/image" + "github.com/containerd/containerd/v2/core/transfer/registry" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/transferutil" +) + +func prepareImageStore(ctx context.Context, parsedReference *referenceutil.ImageReference, options types.ImagePullOptions) (*transferimage.Store, error) { + var storeOpts []transferimage.StoreOpt + if len(options.OCISpecPlatform) > 0 { + storeOpts = append(storeOpts, transferimage.WithPlatforms(options.OCISpecPlatform...)) + } + + unpackEnabled := len(options.OCISpecPlatform) == 1 + if options.Unpack != nil { + unpackEnabled = *options.Unpack + if unpackEnabled && len(options.OCISpecPlatform) != 1 { + return nil, fmt.Errorf("unpacking requires a single platform to be specified (e.g., --platform=amd64)") + } + } + + if unpackEnabled { + platform := options.OCISpecPlatform[0] + snapshotter := options.GOptions.Snapshotter + storeOpts = append(storeOpts, transferimage.WithUnpack(platform, snapshotter)) + } + + return transferimage.NewStore(parsedReference.String(), storeOpts...), nil +} + +func createOCIRegistry(ctx context.Context, parsedReference *referenceutil.ImageReference, gOptions types.GlobalCommandOptions, plainHTTP bool) (*registry.OCIRegistry, func(), error) { + ch, err := dockerconfigresolver.NewCredentialHelper(parsedReference.Domain) + if err != nil { + return nil, nil, err + } + + opts := []registry.Opt{ + registry.WithCredentials(ch), + } + + var tmpHostsDir string + cleanup := func() { + if tmpHostsDir != "" { + os.RemoveAll(tmpHostsDir) + } + } + + // If insecure-registry is set, create a temporary hosts.toml with skip_verify + if gOptions.InsecureRegistry { + tmpHostsDir, err = dockerconfigresolver.CreateTmpHostsConfig(parsedReference.Domain, true) + if err != nil { + log.G(ctx).WithError(err).Warnf("failed to create temporary hosts.toml for %q, continuing without it", parsedReference.Domain) + } else if tmpHostsDir != "" { + opts = append(opts, registry.WithHostDir(tmpHostsDir)) + } + } else if len(gOptions.HostsDir) > 0 { + opts = append(opts, registry.WithHostDir(gOptions.HostsDir[0])) + } + + if isLocalHost, err := docker.MatchLocalhost(parsedReference.Domain); err != nil { + cleanup() + return nil, nil, err + } else if isLocalHost || plainHTTP { + opts = append(opts, registry.WithDefaultScheme("http")) + } + + reg, err := registry.NewOCIRegistry(ctx, parsedReference.String(), opts...) + if err != nil { + cleanup() + return nil, nil, err + } + + return reg, cleanup, nil +} + +func PullImageWithTransfer(ctx context.Context, client *containerd.Client, parsedReference *referenceutil.ImageReference, rawRef string, options types.ImagePullOptions) (*EnsuredImage, error) { + store, err := prepareImageStore(ctx, parsedReference, options) + if err != nil { + return nil, err + } + + progressWriter := options.Stderr + if options.ProgressOutputToStdout { + progressWriter = options.Stdout + } + + fetcher, cleanup, err := createOCIRegistry(ctx, parsedReference, options.GOptions, false) + if err != nil { + return nil, err + } + defer cleanup() + + transferErr := doTransfer(ctx, client, fetcher, store, options.Quiet, progressWriter) + + if transferErr != nil && (errors.Is(transferErr, http.ErrSchemeMismatch) || errutil.IsErrConnectionRefused(transferErr) || errutil.IsErrHTTPResponseToHTTPSClient(transferErr) || errutil.IsErrTLSHandshakeFailure(transferErr)) { + if options.GOptions.InsecureRegistry { + log.G(ctx).WithError(transferErr).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", parsedReference.Domain) + fetcher, cleanup2, err := createOCIRegistry(ctx, parsedReference, options.GOptions, true) + if err != nil { + return nil, err + } + defer cleanup2() + transferErr = doTransfer(ctx, client, fetcher, store, options.Quiet, progressWriter) + } + } + + if transferErr != nil { + return nil, transferErr + } + + imageStore := client.ImageService() + stored, err := store.Get(ctx, imageStore) + if err != nil { + return nil, err + } + + plMatch := platformutil.NewMatchComparerFromOCISpecPlatformSlice(options.OCISpecPlatform) + containerdImage := containerd.NewImageWithPlatform(client, stored, plMatch) + imgConfig, err := getImageConfig(ctx, containerdImage) + if err != nil { + return nil, err + } + + snapshotter := options.GOptions.Snapshotter + snOpt := getSnapshotterOpts(snapshotter) + + return &EnsuredImage{ + Ref: rawRef, + Image: containerdImage, + ImageConfig: *imgConfig, + Snapshotter: snapshotter, + Remote: snOpt.isRemote(), + }, nil +} + +func preparePushStore(pushRef string, options types.ImagePushOptions) (*transferimage.Store, error) { + platformsSlice, err := platformutil.NewOCISpecPlatformSlice(options.AllPlatforms, options.Platforms) + if err != nil { + return nil, err + } + + storeOpts := []transferimage.StoreOpt{} + if len(platformsSlice) > 0 { + storeOpts = append(storeOpts, transferimage.WithPlatforms(platformsSlice...)) + } + + return transferimage.NewStore(pushRef, storeOpts...), nil +} + +func PushImageWithTransfer(ctx context.Context, client *containerd.Client, parsedReference *referenceutil.ImageReference, pushRef, rawRef string, options types.ImagePushOptions) error { + source, err := preparePushStore(pushRef, options) + if err != nil { + return err + } + + progressWriter := io.Discard + if options.Stdout != nil { + progressWriter = options.Stdout + } + + pusher, cleanup, err := createOCIRegistry(ctx, parsedReference, options.GOptions, false) + if err != nil { + return err + } + defer cleanup() + + transferErr := doTransfer(ctx, client, source, pusher, options.Quiet, progressWriter) + + if transferErr != nil && (errors.Is(transferErr, http.ErrSchemeMismatch) || errutil.IsErrConnectionRefused(transferErr) || errutil.IsErrHTTPResponseToHTTPSClient(transferErr) || errutil.IsErrTLSHandshakeFailure(transferErr)) { + if options.GOptions.InsecureRegistry { + log.G(ctx).WithError(transferErr).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", parsedReference.Domain) + pusher, cleanup2, err := createOCIRegistry(ctx, parsedReference, options.GOptions, true) + if err != nil { + return err + } + defer cleanup2() + transferErr = doTransfer(ctx, client, source, pusher, options.Quiet, progressWriter) + } + } + + if transferErr != nil { + log.G(ctx).WithError(transferErr).Errorf("server %q does not seem to support HTTPS", parsedReference.Domain) + log.G(ctx).Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + return transferErr + } + + return nil +} + +func doTransfer(ctx context.Context, client *containerd.Client, src, dst interface{}, quiet bool, progressWriter io.Writer) error { + opts := make([]transfer.Opt, 0, 1) + if !quiet { + pf, done := transferutil.ProgressHandler(ctx, progressWriter) + defer done() + opts = append(opts, transfer.WithProgress(pf)) + } + return client.Transfer(ctx, src, dst, opts...) +} diff --git a/pkg/infoutil/infoutil.go b/pkg/infoutil/infoutil.go index ce6bf9085b1..75cdc7b59e6 100644 --- a/pkg/infoutil/infoutil.go +++ b/pkg/infoutil/infoutil.go @@ -25,7 +25,6 @@ import ( "strings" "time" - "github.com/Masterminds/semver/v3" "github.com/docker/docker/pkg/sysinfo" containerd "github.com/containerd/containerd/v2/client" @@ -146,18 +145,6 @@ func ServerVersion(ctx context.Context, client *containerd.Client) (*dockercompa return v, nil } -func ServerSemVer(ctx context.Context, client *containerd.Client) (*semver.Version, error) { - v, err := client.Version(ctx) - if err != nil { - return nil, err - } - sv, err := semver.NewVersion(v.Version) - if err != nil { - return nil, fmt.Errorf("failed to parse the containerd version %q: %w", v.Version, err) - } - return sv, nil -} - func buildctlVersion() dockercompat.ComponentVersion { buildctlBinary, err := buildkitutil.BuildctlBinary() if err != nil { diff --git a/pkg/inspecttypes/dockercompat/blkio.go b/pkg/inspecttypes/dockercompat/blkio.go new file mode 100644 index 00000000000..3f2275335f0 --- /dev/null +++ b/pkg/inspecttypes/dockercompat/blkio.go @@ -0,0 +1,155 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/* + Portions from https://github.com/moby/moby/blob/v20.10.1/api/types/blkiodev/blkio.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/v20.10.1/NOTICE +*/ + +package dockercompat + +import ( + "fmt" + + "github.com/opencontainers/runtime-spec/specs-go" +) + +type BlkioSettings struct { + BlkioWeight uint16 // Block IO weight (relative weight vs. other containers) + BlkioWeightDevice []*WeightDevice + BlkioDeviceReadBps []*ThrottleDevice + BlkioDeviceWriteBps []*ThrottleDevice + BlkioDeviceReadIOps []*ThrottleDevice + BlkioDeviceWriteIOps []*ThrottleDevice +} + +// From https://github.com/moby/moby/blob/v20.10.1/api/types/blkiodev/blkio.go +// WeightDevice is a structure that holds device:weight pair +type WeightDevice struct { + Path string + Weight uint16 +} + +func (w *WeightDevice) String() string { + return fmt.Sprintf("%s:%d", w.Path, w.Weight) +} + +// ThrottleDevice is a structure that holds device:rate_per_second pair +type ThrottleDevice struct { + Path string + Rate uint64 +} + +func (t *ThrottleDevice) String() string { + return fmt.Sprintf("%s:%d", t.Path, t.Rate) +} + +func getBlkioSettingsFromSpec(spec *specs.Spec, hostConfig *HostConfig) error { + if spec == nil { + return fmt.Errorf("spec cannot be nil") + } + if hostConfig == nil { + return fmt.Errorf("hostConfig cannot be nil") + } + + // Initialize empty arrays by default + hostConfig.BlkioSettings = getDefaultBlkioSettings() + + if spec.Linux == nil || spec.Linux.Resources == nil || spec.Linux.Resources.BlockIO == nil { + return nil + } + + blockIO := spec.Linux.Resources.BlockIO + + // Set block IO weight + if blockIO.Weight != nil { + hostConfig.BlkioWeight = *blockIO.Weight + } + + // Set weight devices + if len(blockIO.WeightDevice) > 0 { + hostConfig.BlkioWeightDevice = make([]*WeightDevice, len(blockIO.WeightDevice)) + dockerCompatWeightDevices, err := toDockerCompatWeightDevices(blockIO.WeightDevice) + if err != nil { + return fmt.Errorf("failed to convert weight devices to dockercompat format: %w", err) + } + for i, dev := range dockerCompatWeightDevices { + hostConfig.BlkioWeightDevice[i] = &dev + } + } + + // Set throttle devices for read BPS + if len(blockIO.ThrottleReadBpsDevice) > 0 { + hostConfig.BlkioDeviceReadBps = make([]*ThrottleDevice, len(blockIO.ThrottleReadBpsDevice)) + dockerCompatThrottleDevices, err := toDockerCompatThrottleDevices(blockIO.ThrottleReadBpsDevice) + if err != nil { + return fmt.Errorf("failed to convert throttle devices to dockercompat format: %w", err) + } + for i, dev := range dockerCompatThrottleDevices { + hostConfig.BlkioDeviceReadBps[i] = &dev + } + } + + // Set throttle devices for write BPS + if len(blockIO.ThrottleWriteBpsDevice) > 0 { + hostConfig.BlkioDeviceWriteBps = make([]*ThrottleDevice, len(blockIO.ThrottleWriteBpsDevice)) + dockerCompatThrottleDevices, err := toDockerCompatThrottleDevices(blockIO.ThrottleWriteBpsDevice) + if err != nil { + return fmt.Errorf("failed to convert throttle devices to dockercompat format: %w", err) + } + for i, dev := range dockerCompatThrottleDevices { + hostConfig.BlkioDeviceWriteBps[i] = &dev + } + } + + // Set throttle devices for read IOPs + if len(blockIO.ThrottleReadIOPSDevice) > 0 { + hostConfig.BlkioDeviceReadIOps = make([]*ThrottleDevice, len(blockIO.ThrottleReadIOPSDevice)) + dockerCompatThrottleDevices, err := toDockerCompatThrottleDevices(blockIO.ThrottleReadIOPSDevice) + if err != nil { + return fmt.Errorf("failed to convert throttle devices to dockercompat format: %w", err) + } + for i, dev := range dockerCompatThrottleDevices { + hostConfig.BlkioDeviceReadIOps[i] = &dev + } + } + + // Set throttle devices for write IOPs + if len(blockIO.ThrottleWriteIOPSDevice) > 0 { + hostConfig.BlkioDeviceWriteIOps = make([]*ThrottleDevice, len(blockIO.ThrottleWriteIOPSDevice)) + dockerCompatThrottleDevices, err := toDockerCompatThrottleDevices(blockIO.ThrottleWriteIOPSDevice) + if err != nil { + return fmt.Errorf("failed to convert throttle devices to dockercompat format: %w", err) + } + for i, dev := range dockerCompatThrottleDevices { + hostConfig.BlkioDeviceWriteIOps[i] = &dev + } + } + return nil +} + +func getDefaultBlkioSettings() BlkioSettings { + return BlkioSettings{ + BlkioWeight: 0, + BlkioWeightDevice: make([]*WeightDevice, 0), + BlkioDeviceReadBps: make([]*ThrottleDevice, 0), + BlkioDeviceWriteBps: make([]*ThrottleDevice, 0), + BlkioDeviceReadIOps: make([]*ThrottleDevice, 0), + BlkioDeviceWriteIOps: make([]*ThrottleDevice, 0), + } +} diff --git a/pkg/inspecttypes/dockercompat/blkioutils_linux.go b/pkg/inspecttypes/dockercompat/blkioutils_linux.go new file mode 100644 index 00000000000..bac75ced0c2 --- /dev/null +++ b/pkg/inspecttypes/dockercompat/blkioutils_linux.go @@ -0,0 +1,98 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockercompat + +import ( + "fmt" + "os" + + "github.com/opencontainers/runtime-spec/specs-go" + "golang.org/x/sys/unix" +) + +func toDockerCompatWeightDevices(weightDevices []specs.LinuxWeightDevice) ([]WeightDevice, error) { + majorMinorToPathMap, err := getDeviceMajorMinorToPathMap() + if err != nil { + return nil, fmt.Errorf("failed to query device paths from major/minor numbers: %w", err) + } + + devices := []WeightDevice{} + for _, weightDevice := range weightDevices { + key := fmt.Sprintf("%d:%d", weightDevice.Major, weightDevice.Minor) + if _, ok := majorMinorToPathMap[key]; ok { + devices = append(devices, WeightDevice{ + Path: majorMinorToPathMap[key], + Weight: *weightDevice.Weight, + }) + } + } + return devices, nil +} + +func toDockerCompatThrottleDevices(throttleDevices []specs.LinuxThrottleDevice) ([]ThrottleDevice, error) { + majorMinorToPathMap, err := getDeviceMajorMinorToPathMap() + if err != nil { + return nil, fmt.Errorf("failed to query device paths from major/minor numbers: %w", err) + } + + devices := []ThrottleDevice{} + for _, throttleDevice := range throttleDevices { + key := fmt.Sprintf("%d:%d", throttleDevice.Major, throttleDevice.Minor) + if _, ok := majorMinorToPathMap[key]; ok { + devices = append(devices, ThrottleDevice{ + Path: majorMinorToPathMap[key], + Rate: throttleDevice.Rate, + }) + } + } + return devices, nil +} + +func getDeviceMajorMinorToPathMap() (map[string]string, error) { + devDir := "/dev" + entries, err := os.ReadDir(devDir) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", devDir, err) + } + + majorMinorToPathMap := make(map[string]string) + for _, ent := range entries { + if ent.IsDir() { + continue + } + devicePath := fmt.Sprintf("%s/%s", devDir, ent.Name()) + osStat, err := os.Stat(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", devicePath, err) + } + // skip char devices + if osStat.Mode()&os.ModeCharDevice != 0 { + continue + } + var unixStat unix.Stat_t + if err := unix.Stat(devicePath, &unixStat); err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", devicePath, err) + } + major := int64(unix.Major(uint64(unixStat.Rdev))) //nolint: unconvert + minor := int64(unix.Minor(uint64(unixStat.Rdev))) //nolint: unconvert + key := fmt.Sprintf("%d:%d", major, minor) + majorMinorToPathMap[key] = devicePath + } + return majorMinorToPathMap, nil +} diff --git a/pkg/inspecttypes/dockercompat/blkioutils_others.go b/pkg/inspecttypes/dockercompat/blkioutils_others.go new file mode 100644 index 00000000000..c5560f099e3 --- /dev/null +++ b/pkg/inspecttypes/dockercompat/blkioutils_others.go @@ -0,0 +1,33 @@ +//go:build !linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockercompat + +import ( + "fmt" + + "github.com/opencontainers/runtime-spec/specs-go" +) + +func toDockerCompatWeightDevices(weightDevices []specs.LinuxWeightDevice) ([]WeightDevice, error) { + return nil, fmt.Errorf("block device weight controls are not supported on this platform") +} + +func toDockerCompatThrottleDevices(throttleDevices []specs.LinuxThrottleDevice) ([]ThrottleDevice, error) { + return nil, fmt.Errorf("block device throttling is not supported on this platform") +} diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 43321456543..5ebfb0c2980 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -44,6 +44,7 @@ import ( "github.com/containerd/go-cni" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" "github.com/containerd/nerdctl/v2/pkg/ipcutil" @@ -181,7 +182,7 @@ type HostConfig struct { MemorySwap int64 // Total memory usage (memory + swap); set `-1` to enable unlimited swap OomKillDisable bool // specifies whether to disable OOM Killer Devices []DeviceMapping // List of devices to map inside the container - LinuxBlkioSettings + BlkioSettings } // From https://github.com/moby/moby/blob/v20.10.1/api/types/types.go#L416-L427 @@ -210,11 +211,11 @@ type Config struct { // TODO: Tty bool // Attach standard streams to a tty, including stdin if it is not closed. // TODO: OpenStdin bool // Open stdin // TODO: StdinOnce bool // If true, close stdin after the 1 attached client disconnects. - Env []string `json:",omitempty"` // List of environment variable to set in the container - Cmd []string `json:",omitempty"` // Command to run when starting the container - // TODO Healthcheck *HealthConfig `json:",omitempty"` // Healthcheck describes how to check the container is healthy + Env []string `json:",omitempty"` // List of environment variable to set in the container + Cmd []string `json:",omitempty"` // Command to run when starting the container + Healthcheck *healthcheck.Healthcheck `json:",omitempty"` // Healthcheck describes how to check the container is healthy // TODO: ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (meaning treat as a command line) (Windows specific). - // TODO: Image string // Name of the image as it was passed by the operator (e.g. could be symbolic) + Image string `json:",omitempty"` // Name of the image as it was passed by the operator (e.g. could be symbolic) Volumes map[string]struct{} `json:",omitempty"` // List of volumes (mounts) used for the container WorkingDir string `json:",omitempty"` // Current directory (PWD) in the command will be launched Entrypoint []string `json:",omitempty"` // Entrypoint to run when starting the container @@ -240,7 +241,7 @@ type ContainerState struct { Error string StartedAt string FinishedAt string - // TODO: Health *Health `json:",omitempty"` + Health *healthcheck.Health `json:",omitempty"` } type NetworkSettings struct { @@ -308,15 +309,6 @@ type NetworkEndpointSettings struct { // TODO DriverOpts map[string]string } -type LinuxBlkioSettings struct { - BlkioWeight uint16 // Block IO weight (relative weight vs. other containers) - BlkioWeightDevice []*specs.LinuxWeightDevice - BlkioDeviceReadBps []*specs.LinuxThrottleDevice - BlkioDeviceWriteBps []*specs.LinuxThrottleDevice - BlkioDeviceReadIOps []*specs.LinuxThrottleDevice - BlkioDeviceWriteIOps []*specs.LinuxThrottleDevice -} - // ContainerFromNative instantiates a Docker-compatible Container from containerd-native Container. func ContainerFromNative(n *native.Container) (*Container, error) { var hostname string @@ -548,6 +540,7 @@ func ContainerFromNative(n *native.Container) (*Container, error) { c.State = cs c.Config = &Config{ Labels: n.Labels, + Image: c.Image, } if n.Labels[labels.Hostname] != "" { hostname = n.Labels[labels.Hostname] @@ -580,6 +573,26 @@ func ContainerFromNative(n *native.Container) (*Container, error) { c.Config.User = n.Labels[labels.User] } + // Add health check config if present in labels + if hConfig, ok := n.Labels[labels.HealthCheck]; ok && hConfig != "" { + healthCheckConfig, err := healthcheck.HealthCheckFromJSON(hConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse healthcheck label: %w", err) + } + c.Config.Healthcheck = healthCheckConfig + } + + // Add health status to container state. + if healthState, ok := n.Labels[labels.HealthState]; ok && healthState != "" { + healthStatus, err := healthcheck.ReadHealthStatusForInspect(n.Labels[labels.StateDir], n.Labels[labels.HealthState]) + if err != nil { + return nil, fmt.Errorf("failed to get health status for inspect: %w", err) + } + if healthStatus != nil { + c.State.Health = healthStatus + } + } + return c, nil } @@ -627,6 +640,16 @@ func ImageFromNative(nativeImage *native.Image) (*Image, error) { Entrypoint: imgOCI.Config.Entrypoint, Labels: imgOCI.Config.Labels, ExposedPorts: portSet, + Image: nativeImage.Image.Name, + } + + // Add health check if present in labels + if healthStr, ok := imgOCI.Config.Labels[labels.HealthCheck]; ok && healthStr != "" { + healthCheckConfig, err := healthcheck.HealthCheckFromJSON(healthStr) + if err != nil { + return nil, fmt.Errorf("failed to parse healthcheck label: %w", err) + } + image.Config.Healthcheck = healthCheckConfig } return image, nil @@ -664,7 +687,7 @@ func statusFromNative(x containerd.Status, labels map[string]string) string { } } -func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSettings, error) { +func networkSettingsFromNative(n *native.NetNS, _ *specs.Spec) (*NetworkSettings, error) { res := &NetworkSettings{ Networks: make(map[string]*NetworkEndpointSettings), } @@ -707,19 +730,12 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting fakeDockerNetworkName := fmt.Sprintf("unknown-%s", x.Name) res.Networks[fakeDockerNetworkName] = nes - if portsLabel, ok := sp.Annotations[labels.Ports]; ok { - var ports []cni.PortMapping - err := json.Unmarshal([]byte(portsLabel), &ports) - if err != nil { - return nil, err - } - nports, err := convertToNatPort(ports) - if err != nil { - return nil, err - } - for portLabel, portBindings := range *nports { - resPortMap[portLabel] = portBindings - } + nports, err := convertToNatPort(n.PortMappings) + if err != nil { + return nil, err + } + for portLabel, portBindings := range *nports { + resPortMap[portLabel] = portBindings } if x.Index == n.PrimaryInterface { @@ -994,78 +1010,3 @@ func ParseMountProperties(option []string) (rw bool, propagation string) { } return } - -func getDefaultLinuxBlkioSettings() LinuxBlkioSettings { - return LinuxBlkioSettings{ - BlkioWeight: 0, - BlkioWeightDevice: make([]*specs.LinuxWeightDevice, 0), - BlkioDeviceReadBps: make([]*specs.LinuxThrottleDevice, 0), - BlkioDeviceWriteBps: make([]*specs.LinuxThrottleDevice, 0), - BlkioDeviceReadIOps: make([]*specs.LinuxThrottleDevice, 0), - BlkioDeviceWriteIOps: make([]*specs.LinuxThrottleDevice, 0), - } -} - -func getBlkioSettingsFromSpec(spec *specs.Spec, hostConfig *HostConfig) error { - if spec == nil { - return fmt.Errorf("spec cannot be nil") - } - if hostConfig == nil { - return fmt.Errorf("hostConfig cannot be nil") - } - - // Initialize empty arrays by default - hostConfig.LinuxBlkioSettings = getDefaultLinuxBlkioSettings() - - if spec.Linux == nil || spec.Linux.Resources == nil || spec.Linux.Resources.BlockIO == nil { - return nil - } - - blockIO := spec.Linux.Resources.BlockIO - - // Set block IO weight - if blockIO.Weight != nil { - hostConfig.BlkioWeight = *blockIO.Weight - } - - // Set weight devices - if len(blockIO.WeightDevice) > 0 { - hostConfig.BlkioWeightDevice = make([]*specs.LinuxWeightDevice, len(blockIO.WeightDevice)) - for i, dev := range blockIO.WeightDevice { - hostConfig.BlkioWeightDevice[i] = &dev - } - } - - // Set throttle devices for read BPS - if len(blockIO.ThrottleReadBpsDevice) > 0 { - hostConfig.BlkioDeviceReadBps = make([]*specs.LinuxThrottleDevice, len(blockIO.ThrottleReadBpsDevice)) - for i, dev := range blockIO.ThrottleReadBpsDevice { - hostConfig.BlkioDeviceReadBps[i] = &dev - } - } - - // Set throttle devices for write BPS - if len(blockIO.ThrottleWriteBpsDevice) > 0 { - hostConfig.BlkioDeviceWriteBps = make([]*specs.LinuxThrottleDevice, len(blockIO.ThrottleWriteBpsDevice)) - for i, dev := range blockIO.ThrottleWriteBpsDevice { - hostConfig.BlkioDeviceWriteBps[i] = &dev - } - } - - // Set throttle devices for read IOPs - if len(blockIO.ThrottleReadIOPSDevice) > 0 { - hostConfig.BlkioDeviceReadIOps = make([]*specs.LinuxThrottleDevice, len(blockIO.ThrottleReadIOPSDevice)) - for i, dev := range blockIO.ThrottleReadIOPSDevice { - hostConfig.BlkioDeviceReadIOps[i] = &dev - } - } - - // Set throttle devices for write IOPs - if len(blockIO.ThrottleWriteIOPSDevice) > 0 { - hostConfig.BlkioDeviceWriteIOps = make([]*specs.LinuxThrottleDevice, len(blockIO.ThrottleWriteIOPSDevice)) - for i, dev := range blockIO.ThrottleWriteIOPSDevice { - hostConfig.BlkioDeviceWriteIOps[i] = &dev - } - } - return nil -} diff --git a/pkg/inspecttypes/dockercompat/dockercompat_test.go b/pkg/inspecttypes/dockercompat/dockercompat_test.go index a31286bff36..a4dbec2d4f3 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat_test.go +++ b/pkg/inspecttypes/dockercompat/dockercompat_test.go @@ -22,15 +22,23 @@ import ( "path/filepath" "runtime" "testing" + "time" "github.com/docker/go-connections/nat" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/opencontainers/runtime-spec/specs-go" "gotest.tools/v3/assert" containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/go-cni" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func TestContainerFromNative(t *testing.T) { @@ -38,9 +46,19 @@ func TestContainerFromNative(t *testing.T) { if err != nil { t.Fatal(err) } - os.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) + filesystem.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) defer os.RemoveAll(tempStateDir) + hc := &healthcheck.Healthcheck{ + Test: []string{"CMD-SHELL", "curl -f http://localhost || exit 1"}, + Interval: time.Second * 30, + Timeout: time.Second * 5, + Retries: 3, + StartPeriod: time.Second * 10, + } + hcJSON, err := hc.ToJSONString() + assert.NilError(t, err) + testcase := []struct { name string n *native.Container @@ -87,9 +105,9 @@ func TestContainerFromNative(t *testing.T) { Driver: "json-file", Opts: map[string]string{}, }, - UTSMode: "host", - Tmpfs: map[string]string{}, - LinuxBlkioSettings: getDefaultLinuxBlkioSettings(), + UTSMode: "host", + Tmpfs: map[string]string{}, + BlkioSettings: getDefaultBlkioSettings(), }, Mounts: []MountPoint{ { @@ -183,9 +201,9 @@ func TestContainerFromNative(t *testing.T) { Driver: "json-file", Opts: map[string]string{}, }, - UTSMode: "host", - Tmpfs: map[string]string{}, - LinuxBlkioSettings: getDefaultLinuxBlkioSettings(), + UTSMode: "host", + Tmpfs: map[string]string{}, + BlkioSettings: getDefaultBlkioSettings(), }, Mounts: []MountPoint{ { @@ -274,9 +292,9 @@ func TestContainerFromNative(t *testing.T) { Driver: "json-file", Opts: map[string]string{}, }, - UTSMode: "host", - Tmpfs: map[string]string{}, - LinuxBlkioSettings: getDefaultLinuxBlkioSettings(), + UTSMode: "host", + Tmpfs: map[string]string{}, + BlkioSettings: getDefaultBlkioSettings(), }, Mounts: []MountPoint{ { @@ -298,6 +316,51 @@ func TestContainerFromNative(t *testing.T) { }, }, }, + { + name: "container with healthcheck label", + n: &native.Container{ + Container: containers.Container{ + Labels: map[string]string{ + labels.HealthCheck: hcJSON, + }, + }, + Spec: &specs.Spec{}, + Process: &native.Process{ + Status: containerd.Status{ + Status: "running", + }, + }, + }, + expected: &Container{ + Created: "0001-01-01T00:00:00Z", + Platform: runtime.GOOS, + Mounts: []MountPoint{}, + State: &ContainerState{ + Status: "running", + Running: true, + Pid: 0, + FinishedAt: "", + }, + HostConfig: &HostConfig{ + LogConfig: loggerLogConfig{Driver: "json-file", Opts: map[string]string{}}, + PortBindings: nat.PortMap{}, + GroupAdd: []string{}, + Tmpfs: map[string]string{}, + UTSMode: "host", + BlkioSettings: getDefaultBlkioSettings(), + }, + NetworkSettings: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{}, + }, + Config: &Config{ + Labels: map[string]string{ + labels.HealthCheck: hcJSON, + }, + Healthcheck: hc, + }, + }, + }, } for _, tc := range testcase { @@ -313,7 +376,7 @@ func TestNetworkSettingsFromNative(t *testing.T) { if err != nil { t.Fatal(err) } - os.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) + filesystem.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) defer os.RemoveAll(tempStateDir) testcase := []struct { @@ -351,11 +414,17 @@ func TestNetworkSettingsFromNative(t *testing.T) { Addrs: []string{"10.0.4.30/24"}, }, }, + PortMappings: []cni.PortMapping{ + { + HostPort: 8075, + ContainerPort: 77, + Protocol: "tcp", + HostIP: "127.0.0.1", + }, + }, }, s: &specs.Spec{ - Annotations: map[string]string{ - "nerdctl/ports": "[{\"HostPort\":8075,\"ContainerPort\":77,\"Protocol\":\"tcp\",\"HostIP\":\"127.0.0.1\"}]", - }, + Annotations: map[string]string{}, }, expected: &NetworkSettings{ Ports: &nat.PortMap{ @@ -511,3 +580,95 @@ func TestCpuSettingsFromNative(t *testing.T) { }) } } + +func TestImageFromNative(t *testing.T) { + t.Run("parses RepoTags/Digests and RootFS Layers", func(t *testing.T) { + createdTime := time.Now().UTC() + + img := native.Image{ + Image: images.Image{ + Name: "myrepo/myimage:custom", + Target: ocispec.Descriptor{ + Digest: digest.Digest("sha256:targetdigest"), + }, + }, + ImageConfigDesc: ocispec.Descriptor{ + Digest: digest.Digest("sha256:configdigest"), + }, + ImageConfig: ocispec.Image{ + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{"sha256:layer1", "sha256:layer2"}, + }, + History: []ocispec.History{ + { + Created: &createdTime, + Author: "test-author", + Comment: "test-comment", + }, + }, + }, + } + + out, err := ImageFromNative(&img) + assert.NilError(t, err) + + // ID, tags, digests + assert.Equal(t, out.ID, "sha256:configdigest") + assert.Equal(t, out.RepoTags[0], "myrepo/myimage:custom") + assert.Equal(t, out.RepoDigests[0], "myrepo/myimage@sha256:targetdigest") + + // RootFS + assert.DeepEqual(t, out.RootFS.Layers, []string{"sha256:layer1", "sha256:layer2"}) + + // History + assert.Equal(t, out.Author, "test-author") + assert.Equal(t, out.Comment, "test-comment") + assert.Equal(t, out.Created, createdTime.Format(time.RFC3339Nano)) + }) + + t.Run("parses Healthcheck label", func(t *testing.T) { + testcases := []struct { + name string + labels map[string]string + expected *healthcheck.Healthcheck + }{ + { + name: "Valid Healthcheck Label", + labels: map[string]string{ + labels.HealthCheck: `{ + "test": ["CMD-SHELL", "curl -f http://localhost/ || exit 1"], + "interval": 30000000000, + "timeout": 5000000000 + }`, + }, + expected: &healthcheck.Healthcheck{ + Test: []string{"CMD-SHELL", "curl -f http://localhost/ || exit 1"}, + Interval: time.Second * 30, + Timeout: time.Second * 5, + }, + }, + { + name: "No Healthcheck Label", + labels: map[string]string{}, + expected: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + img := native.Image{ + ImageConfig: ocispec.Image{ + Config: ocispec.ImageConfig{ + Labels: tc.labels, + }, + }, + } + + out, err := ImageFromNative(&img) + assert.NilError(t, err) + assert.DeepEqual(t, out.Config.Healthcheck, tc.expected) + }) + } + }) +} diff --git a/pkg/inspecttypes/native/container.go b/pkg/inspecttypes/native/container.go index de015dd5f94..1bd421a2d62 100644 --- a/pkg/inspecttypes/native/container.go +++ b/pkg/inspecttypes/native/container.go @@ -21,6 +21,7 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/go-cni" ) // Container corresponds to a containerd-native container object. @@ -43,6 +44,7 @@ type NetNS struct { // Zero means unset. PrimaryInterface int `json:"PrimaryInterface,omitempty"` Interfaces []NetInterface `json:"Interfaces,omitempty"` + PortMappings []cni.PortMapping } // NetInterface wraps net.Interface for JSON marshallability. diff --git a/pkg/internal/filesystem/consts.go b/pkg/internal/filesystem/consts.go new file mode 100644 index 00000000000..03fbe6953ed --- /dev/null +++ b/pkg/internal/filesystem/consts.go @@ -0,0 +1,50 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "io" + "os" + "path/filepath" +) + +const ( + // Max size of path components + pathComponentMaxLength = 255 + privateFilePermission = os.FileMode(0o600) + privateDirPermission = os.FileMode(0o700) +) + +var ( + // Lightweight indirection to ease testing + ioCopy = io.Copy + + // Location (under XDG data home) used for markers and backups + filesystemOpsPath = "filesystem-ops" + // Suffix for markers and backup files + markerSuffix = "in-progress" + backupSuffix = "backup" + + // holdLocation points to where markers and backup files will be held. This should NOT be let to /tmp, + // but instead be explicitly configured with SetFilesystemOpsDirectory. + holdLocation = os.TempDir() +) + +func SetFilesystemOpsDirectory(path string) error { + holdLocation = filepath.Join(path, filesystemOpsPath) + return os.MkdirAll(holdLocation, privateDirPermission) +} diff --git a/pkg/internal/filesystem/errors.go b/pkg/internal/filesystem/errors.go new file mode 100644 index 00000000000..88c38119927 --- /dev/null +++ b/pkg/internal/filesystem/errors.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import "errors" + +var ( + ErrLockFail = errors.New("failed to acquire lock") + ErrUnlockFail = errors.New("failed to release lock") + ErrLockIsNil = errors.New("nil lock") + ErrInvalidPath = errors.New("invalid path") + ErrFilesystemFailure = errors.New("filesystem error") +) diff --git a/pkg/internal/filesystem/helpers.go b/pkg/internal/filesystem/helpers.go new file mode 100644 index 00000000000..75ce37109b8 --- /dev/null +++ b/pkg/internal/filesystem/helpers.go @@ -0,0 +1,257 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const ( + removeMarker = "remove" +) + +func ensureRecovery(filename string) (err error) { + // Check for a marker file. + // No marker means all fine, nothing to be done. + // Any other error is a hard error. + var op string + if op, err = markerRead(filename); err != nil { + if os.IsNotExist(err) { + err = nil + } + return err + } + + // We have a marker. We know we were interrupted. + // Check for a possible backup file. + var exists bool + if exists, err = backupExists(filename); err != nil { + return err + } + + // If we have a backup, restore from it + if exists { + if err = backupRestore(filename); err != nil { + return err + } + _ = backupRemove(filename) + } else { + // We do not see a backup. + // Do we have a final destination then? + _, err = os.Stat(filename) + // Any error but does not exist is a hard error. + if err != nil && !os.IsNotExist(err) { + return err + } + + // If we do NOT have a destination, nothing to be done - we already took care of it, though we were interrupted + // mid-recovery. + + // If we DO have a destination: + if err == nil { + // Either: + // - there was no original, so we need to remove it (marker contains `remove`) + // - or we were interrupted ALSO during the recovery attempt, after the backup restore above and before deleting the marker + // in which case we do NOT want to remove as the file has already been restored. + if op == removeMarker { + // Errors on remove are hard errors. + if err = os.Remove(filename); err != nil { + return err + } + } + } + } + + // Ok, we successfully recovered, now, remove the marker and return + return markerRemove(filename) +} + +// backupSave does perform a backup of the provided file at `path`. +func backupSave(path string) error { + return internalCopy(path, backupLocation(path)) +} + +// backupRestore restores a file from its backup. +// On success the backup is deleted. +func backupRestore(path string) error { + err := internalCopy(backupLocation(path), path) + if err == nil { + err = os.Remove(backupLocation(path)) + } + + return err +} + +func backupRemove(path string) error { + return os.Remove(backupLocation(path)) +} + +// backupExists checks if a backup file exists for file located at `path`. +func backupExists(path string) (bool, error) { + _, err := os.Stat(backupLocation(path)) + if os.IsNotExist(err) { + return false, nil + } + + return err == nil, err +} + +// backupLocation returns the location of the backup for path. +func backupLocation(path string) string { + return location(path) + backupSuffix +} + +// markerCreate saves a marker file with the current time. +// Markers are used to indicate an operation is in progress and allow for disaster recovery. +func markerCreate(path string, op string) (err error) { + var marker *os.File + marker, err = os.OpenFile(markerLocation(path), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, privateFilePermission) + if err != nil { + return err + } + + defer func() { + // If we errored on sync or close, remove the marker (ignore removal errors) + if err = errors.Join(err, marker.Close()); err != nil { + _ = markerRemove(path) + } + }() + + _, err = marker.Write([]byte(op)) + if err != nil { + return err + } + + return marker.Sync() +} + +// markerRead reads the content of a marker file if it exists (contains the time at which it was created). +func markerRead(path string) (string, error) { + data, err := os.ReadFile(markerLocation(path)) + if err != nil { + return "", err + } + + return string(data), nil +} + +// markerRemove deletes a marker file. +func markerRemove(path string) error { + return os.Remove(markerLocation(path)) +} + +// markerLocation returns the location of the marker file for a given path. +func markerLocation(path string) string { + return location(path) + markerSuffix +} + +// location returns the filesystem-ops path associated with a given file (where marker and backups are located). +// The location is unique (see hash), and shows the first 16 characters of the filename for readability. +func location(path string) string { + dir := filepath.Dir(path) + base := filepath.Base(path) + pretty := base + // Ensure that we do not blow up filesystem length limits + if len(pretty) > 16 { + pretty = pretty[:16] + } + return filepath.Join(holdLocation, hash(dir)+"-"+pretty+"-"+hash(base)+"-") +} + +// hash does return the first 8 characters of the shasum256 of the provided string. +// Chances of collision are 50% with 77,000 *simultaneous* entries. +func hash(s string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(s)))[0:8] +} + +// internalCopy performs a simple copy from source to destination. +// This in itself is not safe. +func internalCopy(sourcePath, destinationPath string) (err error) { + var source *os.File + + // Open source + source, err = os.OpenFile(sourcePath, os.O_RDONLY, privateFilePermission) + if err != nil { + return err + } + + defer func() { + err = errors.Join(err, source.Close()) + }() + + // Read file length + srcInfo, err := source.Stat() + if err != nil { + return err + } + + return fileWrite(source, srcInfo.Size(), destinationPath, privateFilePermission, srcInfo.ModTime()) +} + +// fileWrite performs a simple write to the destination file from the provided io.Reader. +// This in itself is not safe. +func fileWrite(source io.Reader, size int64, destinationPath string, perm os.FileMode, mTime time.Time) (err error) { + var destination *os.File + mustClose := true + + // Open destination + destination, err = os.OpenFile(destinationPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) + if err != nil { + return err + } + + defer func() { + // Close if need be. + if mustClose { + err = errors.Join(err, destination.Close()) + } + }() + + // Copy over + var n int64 + n, err = ioCopy(destination, source) + if err != nil { + return err + } + + if n < size { + return io.ErrShortWrite + } + + // Ensure data is committed + if err = destination.Sync(); err != nil { + return err + } + + err = destination.Close() + mustClose = false + if err != nil { + return err + } + + if !mTime.IsZero() { + err = os.Chtimes(destinationPath, mTime, mTime) + } + + return err +} diff --git a/pkg/internal/filesystem/lock.go b/pkg/internal/filesystem/lock.go new file mode 100644 index 00000000000..d993e380b41 --- /dev/null +++ b/pkg/internal/filesystem/lock.go @@ -0,0 +1,124 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Portions from internal go +// +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:LICENSE + +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/cmd/go/internal/lockedfile/internal/filelock/filelock.go + +package filesystem + +import ( + "errors" + "os" + "runtime" +) + +// Lock places an advisory write lock on the file, blocking until it can be locked. +// +// If Lock returns nil, no other process will be able to place a read or write lock on the file until +// this process exits, closes f, or calls Unlock on it. +func Lock(path string) (file *os.File, err error) { + return commonlock(path, writeLock) +} + +// ReadOnlyLock places an advisory read lock on the file, blocking until it can be locked. +// +// If ReadOnlyLock returns nil, no other process will be able to place a write lock on +// the file until this process exits, closes f, or calls Unlock on it. +func ReadOnlyLock(path string) (file *os.File, err error) { + return commonlock(path, readLock) +} + +func commonlock(path string, mode lockType) (file *os.File, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrLockFail, err, file.Close()) + } + }() + + if runtime.GOOS == "windows" { + // LockFileEx does not work on directories, so check what we have first. + // If that is a dir, swap out the path for a sidecar file instead (not inside the directory). + // Note that this cannot be done in platform specific implementation without moving all the fd Open and Close + // logic over there, which is undesirable. + if sl, err := os.Stat(path); err == nil && sl.IsDir() { + path = path + ".nerdctl.lock" + } + } + + file, err = os.Open(path) + if errors.Is(err, os.ErrNotExist) { + file, err = os.OpenFile(path, os.O_RDONLY|os.O_CREATE, privateFilePermission) + } + if err != nil { + return nil, err + } + + if err = platformSpecificLock(file, mode); err != nil { + return nil, errors.Join(err, file.Close()) + } + + return file, nil +} + +// Unlock removes an advisory lock placed on f by this process. +func Unlock(lock *os.File) error { + if lock == nil { + return ErrLockIsNil + } + + if err := errors.Join(platformSpecificUnlock(lock), lock.Close()); err != nil { + return errors.Join(ErrUnlockFail, err) + } + + return nil +} + +// WithLock executes the provided function after placing a write lock on `path`. +// The lock is released once the function has been run, regardless of outcome. +func WithLock(path string, function func() error) (err error) { + file, err := Lock(path) + if err != nil { + return err + } + + defer func() { + err = errors.Join(Unlock(file), err) + }() + + return function() +} + +// WithReadOnlyLock executes the provided function after placing a read lock on `path`. +// The lock is released once the function has been run, regardless of outcome. +func WithReadOnlyLock(path string, function func() error) (err error) { + file, err := ReadOnlyLock(path) + if err != nil { + return err + } + + defer func() { + err = errors.Join(Unlock(file), err) + }() + + return function() +} diff --git a/pkg/internal/filesystem/lock_test.go b/pkg/internal/filesystem/lock_test.go new file mode 100644 index 00000000000..e3405357be1 --- /dev/null +++ b/pkg/internal/filesystem/lock_test.go @@ -0,0 +1,273 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem_test + +import ( + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" +) + +const ( + mainroutine1 uint32 = 11 + mainroutine2 uint32 = 12 + routine1 uint32 = 1 + routine2 uint32 = 2 + routine3 uint32 = 3 +) + +func TestLockDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + // Lock acquisition + file, err := filesystem.Lock(tempDir) + assert.NilError(t, err, "acquiring a lock should succeed") + err = filesystem.Unlock(file) + assert.NilError(t, err, "releasing a lock should succeed") + + file, err = filesystem.ReadOnlyLock(tempDir) + assert.NilError(t, err, "acquiring a read-only lock should succeed") + file2, err := filesystem.ReadOnlyLock(tempDir) + assert.NilError(t, err, "acquiring another read-only lock should succeed") + err = filesystem.Unlock(file) + assert.NilError(t, err, "releasing a read-only lock should succeed") + err = filesystem.Unlock(file2) + assert.NilError(t, err, "releasing another read-only lock should succeed") +} + +func TestLockFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + lock, err := os.CreateTemp(tempDir, "lockfile") + assert.NilError(t, err, "creating temp file should succeed") + defer lock.Close() + // Lock acquisition + file, err := filesystem.Lock(lock.Name()) + assert.NilError(t, err, "acquiring a lock should succeed") + err = filesystem.Unlock(file) + assert.NilError(t, err, "releasing a lock should succeed") + + file, err = filesystem.ReadOnlyLock(lock.Name()) + assert.NilError(t, err, "acquiring a read-only lock should succeed") + file2, err := filesystem.ReadOnlyLock(lock.Name()) + assert.NilError(t, err, "acquiring another read-only lock should succeed") + err = filesystem.Unlock(file) + assert.NilError(t, err, "releasing a read-only lock should succeed") + err = filesystem.Unlock(file2) + assert.NilError(t, err, "releasing another read-only lock should succeed") +} + +func TestLockWriteConcurrent(t *testing.T) { + t.Parallel() + + var waitGroup sync.WaitGroup + + var concurrentKey uint32 + + tempDir := t.TempDir() + + waitGroup.Add(2) + + // Start a lock, set the key, sleep 1s and confirm the key is still the same + go func() { + defer waitGroup.Done() + + lErr := filesystem.WithLock(tempDir, func() error { + atomic.StoreUint32(&concurrentKey, routine1) + + time.Sleep(1 * time.Second) + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine1) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + // Wait 0.5s, start another lock, set the key, sleep 1s and confirm the key is still the same + go func() { + defer waitGroup.Done() + + time.Sleep(500 * time.Millisecond) + + lErr := filesystem.WithLock(tempDir, func() error { + atomic.StoreUint32(&concurrentKey, routine2) + + time.Sleep(1 * time.Second) + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine2) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + // Start a lock, set the key, wait 1s, confirm the key is still the same + lErr := filesystem.WithLock(tempDir, func() error { + atomic.StoreUint32(&concurrentKey, mainroutine1) + + time.Sleep(1 * time.Second) + assert.Equal(t, atomic.LoadUint32(&concurrentKey), mainroutine1) + + return nil + }) + assert.NilError(t, lErr, "locking should not error") + + // Wait 0.75s, start a lock, set the key, sleep 1s, confirm the key is unchanged + time.Sleep(750 * time.Millisecond) + + lErr = filesystem.WithLock(tempDir, func() error { + atomic.StoreUint32(&concurrentKey, mainroutine2) + + time.Sleep(1 * time.Second) + assert.Equal(t, atomic.LoadUint32(&concurrentKey), mainroutine2) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + + waitGroup.Wait() +} + +func TestLockMultiRead(t *testing.T) { + t.Parallel() + + var waitGroup sync.WaitGroup + + var concurrentKey uint32 + + tempDir := t.TempDir() + + waitGroup.Add(3) + + // Start a readonly lock immediately + // Then wait 1s inside the lock - confirm the key got changed by the second read routine + go func() { + t.Log("Entering routine 1") + + defer waitGroup.Done() + + lErr := filesystem.WithReadOnlyLock(tempDir, func() error { + t.Log("Entering routine 1 read lock") + + atomic.StoreUint32(&concurrentKey, routine1) + + time.Sleep(1 * time.Second) + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine2) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + // Wait 0.5s before locking, then change the key + go func() { + t.Log("Entering routine 2") + + defer waitGroup.Done() + + time.Sleep(500 * time.Millisecond) + + lErr := filesystem.WithReadOnlyLock(tempDir, func() error { + t.Log("Entering routine 2 read lock") + + atomic.StoreUint32(&concurrentKey, routine2) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + time.Sleep(50 * time.Millisecond) + // Start a write lock, confirm we have waited for the read locks to finish, change the key + go func() { + t.Log("Entering routine 3") + + defer waitGroup.Done() + + lErr := filesystem.WithLock(tempDir, func() error { + t.Log("Entering routine 3 write lock") + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine2) + + atomic.StoreUint32(&concurrentKey, routine3) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + waitGroup.Wait() +} + +func TestLockWriteBlocksRead(t *testing.T) { + t.Parallel() + + var waitGroup sync.WaitGroup + + var concurrentKey uint32 + + tempDir := t.TempDir() + + waitGroup.Add(2) + + // Start a lock, set the key, sleep 1s and confirm the key is still the same + go func() { + defer waitGroup.Done() + + lErr := filesystem.WithLock(tempDir, func() error { + time.Sleep(1 * time.Second) + + atomic.StoreUint32(&concurrentKey, routine1) + + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine1) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + time.Sleep(50 * time.Millisecond) + + // Start a readonly lock immediately + // Confirm the key has been set by the write lock + go func() { + defer waitGroup.Done() + + lErr := filesystem.WithReadOnlyLock(tempDir, func() error { + assert.Equal(t, atomic.LoadUint32(&concurrentKey), routine1) + + return nil + }) + + assert.NilError(t, lErr, "locking should not error") + }() + + waitGroup.Wait() +} diff --git a/pkg/internal/filesystem/lock_unix.go b/pkg/internal/filesystem/lock_unix.go new file mode 100644 index 00000000000..4f37a2fa368 --- /dev/null +++ b/pkg/internal/filesystem/lock_unix.go @@ -0,0 +1,59 @@ +//go:build unix + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Portions from internal go +// +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:LICENSE + +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/cmd/go/internal/lockedfile/internal/filelock/filelock_unix.go + +package filesystem + +import ( + "errors" + "os" + "syscall" +) + +type lockType int16 + +const ( + readLock lockType = syscall.LOCK_SH + writeLock lockType = syscall.LOCK_EX +) + +func platformSpecificLock(file *os.File, lockType lockType) error { + var err error + + for { + err = syscall.Flock(int(file.Fd()), int(lockType)) + if !errors.Is(err, syscall.EINTR) { + break + } + } + + return err +} + +func platformSpecificUnlock(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} diff --git a/pkg/internal/filesystem/lock_windows.go b/pkg/internal/filesystem/lock_windows.go new file mode 100644 index 00000000000..b81d71d0ff3 --- /dev/null +++ b/pkg/internal/filesystem/lock_windows.go @@ -0,0 +1,58 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Portions from internal go +// +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:LICENSE + +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/cmd/go/internal/lockedfile/internal/filelock/filelock_windows.go + +package filesystem + +import ( + "os" + + "golang.org/x/sys/windows" +) + +type lockType uint32 + +const ( + // https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203(v=vs.85).aspx + readLock lockType = 0 + writeLock lockType = windows.LOCKFILE_EXCLUSIVE_LOCK + + reserved = 0 + allBytes = ^uint32(0) +) + +func platformSpecificLock(file *os.File, lockType lockType) error { + return windows.LockFileEx( + windows.Handle(file.Fd()), + uint32(lockType), + reserved, + allBytes, + allBytes, + new(windows.Overlapped)) +} + +func platformSpecificUnlock(file *os.File) error { + return windows.UnlockFileEx(windows.Handle(file.Fd()), reserved, allBytes, allBytes, new(windows.Overlapped)) +} diff --git a/pkg/internal/filesystem/os.go b/pkg/internal/filesystem/os.go new file mode 100644 index 00000000000..62411c5250f --- /dev/null +++ b/pkg/internal/filesystem/os.go @@ -0,0 +1,50 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "errors" + "os" +) + +func ReadFile(filename string) (data []byte, err error) { + if err = ensureRecovery(filename); err != nil { + return nil, err + } + + data, err = os.ReadFile(filename) + if err != nil { + return nil, errors.Join(ErrFilesystemFailure, err) + } + + return data, nil +} + +func Stat(filename string) (os.FileInfo, error) { + if err := ensureRecovery(filename); err != nil { + return nil, errors.Join(ErrFilesystemFailure, err) + } + + return os.Stat(filename) +} + +// WriteFile implements an atomic and durable alternative to os.WriteFile that does not change inodes (unlike the usual +// approach on atomic writes that relies on renaming files). +func WriteFile(filename string, data []byte, perm os.FileMode) error { + _, err := WriteFileWithRollback(filename, data, perm) + return err +} diff --git a/pkg/internal/filesystem/path.go b/pkg/internal/filesystem/path.go new file mode 100644 index 00000000000..d0b99df7f40 --- /dev/null +++ b/pkg/internal/filesystem/path.go @@ -0,0 +1,47 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "errors" + "strings" +) + +var ( + errForbiddenChars = errors.New("forbidden characters in path component") + errForbiddenKeywords = errors.New("forbidden keywords in path component") + errInvalidPathTooLong = errors.New("path component must be shorter than 256 characters") + errInvalidPathEmpty = errors.New("path component cannot be empty") +) + +// ValidatePathComponent will enforce os specific filename restrictions on a single path component. +func ValidatePathComponent(pathComponent string) error { + // https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits + if len(pathComponent) > pathComponentMaxLength { + return errors.Join(ErrInvalidPath, errInvalidPathTooLong) + } + + if strings.TrimSpace(pathComponent) == "" { + return errors.Join(ErrInvalidPath, errInvalidPathEmpty) + } + + if err := validatePlatformSpecific(pathComponent); err != nil { + return errors.Join(ErrInvalidPath, err) + } + + return nil +} diff --git a/pkg/internal/filesystem/path_test.go b/pkg/internal/filesystem/path_test.go new file mode 100644 index 00000000000..fa3d2b2fbf2 --- /dev/null +++ b/pkg/internal/filesystem/path_test.go @@ -0,0 +1,85 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem_test + +import ( + "fmt" + "runtime" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" +) + +func TestFilesystemRestrictions(t *testing.T) { + t.Parallel() + + invalid := []string{ + "/", + "/start", + "mid/dle", + "end/", + ".", + "..", + "", + fmt.Sprintf("A%0255s", "A"), + } + + valid := []string{ + fmt.Sprintf("A%0254s", "A"), + "test", + "test-hyphen", + ".start.dot", + "mid.dot", + "∞", + } + + if runtime.GOOS == "windows" { + invalid = append(invalid, []string{ + "\\start", + "mid\\dle", + "end\\", + "\\", + "\\.", + "com².whatever", + "lpT2", + "Prn.", + "nUl", + "AUX", + "AA", + "A:A", + "A\"A", + "A|A", + "A?A", + "A*A", + "end.dot.", + "end.space ", + }...) + } + + for _, v := range invalid { + err := filesystem.ValidatePathComponent(v) + assert.ErrorIs(t, err, filesystem.ErrInvalidPath, v) + } + + for _, v := range valid { + err := filesystem.ValidatePathComponent(v) + assert.NilError(t, err, v) + } +} diff --git a/pkg/store/filestore_unix.go b/pkg/internal/filesystem/path_unix.go similarity index 75% rename from pkg/store/filestore_unix.go rename to pkg/internal/filesystem/path_unix.go index b694b6fc744..4db2bd42e48 100644 --- a/pkg/store/filestore_unix.go +++ b/pkg/internal/filesystem/path_unix.go @@ -16,14 +16,14 @@ limitations under the License. */ -package store +package filesystem import ( "fmt" "regexp" ) -// Note that Darwin has different restrictions - though, we do not support Darwin at this point... +// Note that Darwin has different restrictions on colons. // https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names var ( disallowedKeywords = regexp.MustCompile(`^([.]|[.][.])$`) @@ -32,11 +32,11 @@ var ( func validatePlatformSpecific(pathComponent string) error { if reservedCharacters.MatchString(pathComponent) { - return fmt.Errorf("identifier %q cannot contain any of the following characters: %q", pathComponent, reservedCharacters) + return fmt.Errorf("%w: %q (%q)", errForbiddenChars, pathComponent, reservedCharacters) } if disallowedKeywords.MatchString(pathComponent) { - return fmt.Errorf("identifier %q cannot be any of the reserved keywords: %q", pathComponent, disallowedKeywords) + return fmt.Errorf("%w: %q (%q)", errForbiddenKeywords, pathComponent, disallowedKeywords) } return nil diff --git a/pkg/store/filestore_windows.go b/pkg/internal/filesystem/path_windows.go similarity index 78% rename from pkg/store/filestore_windows.go rename to pkg/internal/filesystem/path_windows.go index 7c599803cb5..1853d3aed21 100644 --- a/pkg/store/filestore_windows.go +++ b/pkg/internal/filesystem/path_windows.go @@ -14,9 +14,10 @@ limitations under the License. */ -package store +package filesystem import ( + "errors" "fmt" "regexp" ) @@ -26,19 +27,21 @@ import ( var ( disallowedKeywords = regexp.MustCompile(`(?i)^(con|prn|nul|aux|com[1-9¹²³]|lpt[1-9¹²³])([.].*)?$`) reservedCharacters = regexp.MustCompile(`[\x{0}-\x{1f}<>:"/\\|?*]`) + + errNoEndingSpaceDot = errors.New("component cannot end with a space or dot") ) func validatePlatformSpecific(pathComponent string) error { if reservedCharacters.MatchString(pathComponent) { - return fmt.Errorf("identifier %q cannot contain any of the following characters: %q", pathComponent, reservedCharacters) + return fmt.Errorf("%w: %q (%q)", errForbiddenChars, pathComponent, reservedCharacters) } if disallowedKeywords.MatchString(pathComponent) { - return fmt.Errorf("identifier %q cannot be any of the reserved keywords: %q", pathComponent, disallowedKeywords) + return fmt.Errorf("%w: %q (%q)", errForbiddenKeywords, pathComponent, disallowedKeywords) } if pathComponent[len(pathComponent)-1:] == "." || pathComponent[len(pathComponent)-1:] == " " { - return fmt.Errorf("identifier %q cannot end with a space or dot", pathComponent) + return fmt.Errorf("%w: %q", errNoEndingSpaceDot, pathComponent) } return nil diff --git a/pkg/internal/filesystem/umask.go b/pkg/internal/filesystem/umask.go new file mode 100644 index 00000000000..d3a69c7fdb1 --- /dev/null +++ b/pkg/internal/filesystem/umask.go @@ -0,0 +1,49 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "math" + "sync" +) + +var ( + mu sync.Mutex + cMask = -1 +) + +// GetUmask retrieves the current umask. +func GetUmask() uint32 { + if cMask != -1 { + return uint32(cMask) + } + + mu.Lock() + defer mu.Unlock() + + cMask = umask(0) + + // FIXME: one day... we will get rid of 32 bits arm... + cMask64 := int64(cMask) + if cMask64 > math.MaxUint32 || cMask < 0 { + panic("currently set user umask is out of range") + } + + _ = umask(cMask) + + return uint32(cMask) +} diff --git a/pkg/internal/filesystem/umask_test.go b/pkg/internal/filesystem/umask_test.go new file mode 100644 index 00000000000..7b07ff68841 --- /dev/null +++ b/pkg/internal/filesystem/umask_test.go @@ -0,0 +1,95 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem_test + +import ( + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" + "sync/atomic" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" +) + +func TestUmask(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("windows does not have a unix-style umask") + } + + userHostReportedUmask, err := exec.Command("sh", "-c", "umask").CombinedOutput() + assert.NilError(t, err, fmt.Sprintf( + "umask command should succeed (output: %s)", + userHostReportedUmask, + )) + expectedUmask, err := strconv.ParseInt(strings.TrimSpace(string(userHostReportedUmask)), 8, 0) + assert.NilError( + t, + err, + fmt.Sprintf("umask command should have returned parsable output (was: %s)", userHostReportedUmask), + ) + + userMask := filesystem.GetUmask() + assert.Equal(t, expectedUmask, int64(userMask), "system reported umask and implementation umask are the same") + + userHostReportedUmask, err = exec.Command("sh", "-c", "umask").CombinedOutput() + assert.NilError(t, err) + expectedUmask, err = strconv.ParseInt(strings.TrimSpace(string(userHostReportedUmask)), 8, 0) + assert.NilError(t, err) + + assert.Equal(t, expectedUmask, int64(userMask), "system reported umask has not changed") +} + +func TestUmaskConcurrent(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("windows does not have a unix-style umask") + } + + userHostReportedUmask, err := exec.Command("sh", "-c", "umask").Output() + assert.NilError(t, err) + expectedUmask, err := strconv.ParseInt(strings.TrimSpace(string(userHostReportedUmask)), 8, 0) + assert.NilError(t, err) + + var counter int32 = 100 + + ch := make(chan uint32) + + for range counter { + go func(ch chan uint32) { + u := filesystem.GetUmask() + if atomic.AddInt32(&counter, -1) == 0 { + ch <- u + } + }(ch) + } + + ret := <-ch + assert.Equal(t, expectedUmask, int64(ret)) + userHostReportedUmask, err = exec.Command("sh", "-c", "umask").Output() + assert.NilError(t, err) + newUmask, err := strconv.ParseInt(strings.TrimSpace(string(userHostReportedUmask)), 8, 0) + assert.NilError(t, err) + assert.Equal(t, newUmask, expectedUmask, "system reported umask has not changed") +} diff --git a/pkg/internal/filesystem/umask_unix.go b/pkg/internal/filesystem/umask_unix.go new file mode 100644 index 00000000000..89dc6d87076 --- /dev/null +++ b/pkg/internal/filesystem/umask_unix.go @@ -0,0 +1,25 @@ +//go:build unix + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import "syscall" + +func umask(mask int) int { + return syscall.Umask(mask) +} diff --git a/pkg/internal/filesystem/umask_windows.go b/pkg/internal/filesystem/umask_windows.go new file mode 100644 index 00000000000..dadaec0abb3 --- /dev/null +++ b/pkg/internal/filesystem/umask_windows.go @@ -0,0 +1,21 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +func umask(_ int) int { + return 0 +} diff --git a/pkg/internal/filesystem/writefile_rename.go b/pkg/internal/filesystem/writefile_rename.go new file mode 100644 index 00000000000..8a317139bd0 --- /dev/null +++ b/pkg/internal/filesystem/writefile_rename.go @@ -0,0 +1,112 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "time" +) + +// WriteFileWithRename is a drop-in replacement for os.WriteFile, with the same signature and almost identical behavior +// (see note below on inodes). +// Unlike os.WriteFile, it does provide extra guarantees: +// - Atomicity (provided by rename - *mostly* atomic, except on OS crash, where rename behavior is undefined) +// - Durability (sync-ed) +// Note that: +// - this does not provide Isolation (a locking mechanism needs to be used independently to enforce that) +// - Consistency is orthogonal here, and high-level operations that expect it across a set of unrelated ops need to +// implement locking, rollback, and disaster recovery +// - this will change inode in case the file already exist - therefore, there are cases where this cannot be used +// (specifically if a file is mounted inside a container) - these are the exception though, and in almost all cases, +// this method should be preferred over os.WriteFile +// Finally note that we do not do anything smart wrt symlinks. +// User is expected to resolve symlink for the destination before calling this if needed. +func WriteFileWithRename(filename string, data []byte, perm os.FileMode) error { + return CopyToFileWithRename(filename, bytes.NewBuffer(data), int64(len(data)), perm, time.Time{}) +} + +// CopyToFileWithRename is an atomic wrapper around io.Copy(file, reader). See notes above in WriteFile for details. +func CopyToFileWithRename(filename string, reader io.Reader, dataSize int64, perm os.FileMode, mTime time.Time) (err error) { + var tmpFile *os.File + mustClose := true + + defer func() { + // Close if we have not already + if mustClose { + err = errors.Join(err, tmpFile.Close()) + } + + // On error, wrap it into ErrFilesystemFailure (and ensure we don't leak temp files) + if err != nil { + if tmpFile != nil { + err = errors.Join(err, os.Remove(tmpFile.Name())) + } + err = errors.Join(ErrFilesystemFailure, err) + } + }() + + // Ensure we set permission honoring umask to be compatible with os.WriteFile + perm = (^os.FileMode(GetUmask())) & perm + + // Create a new temp file. + tmpFile, err = os.CreateTemp(filepath.Dir(filename), ".tmp-"+filepath.Base(filename)) + if err != nil { + return err + } + + // Set permissions + if err = os.Chmod(tmpFile.Name(), perm); err != nil { + return err + } + + // Write data + n, err := ioCopy(tmpFile, reader) + if err == nil && n < dataSize { + return io.ErrShortWrite + } + + if err != nil { + return err + } + + // Sync it, ensuring the data cannot be lost + if err = tmpFile.Sync(); err != nil { + return err + } + + // Close + if err = tmpFile.Close(); err != nil { + return err + } + + mustClose = false + + // Set mtime if requested + if !mTime.IsZero() { + if err = os.Chtimes(tmpFile.Name(), mTime, mTime); err != nil { + return err + } + } + + // Rename to final destination (hopefully on the same volume) + // NOTE: this is atomic in *most* cases - it might not be if the OS crashes. + return os.Rename(tmpFile.Name(), filename) +} diff --git a/pkg/internal/filesystem/writefile_rollback.go b/pkg/internal/filesystem/writefile_rollback.go new file mode 100644 index 00000000000..4608526406f --- /dev/null +++ b/pkg/internal/filesystem/writefile_rollback.go @@ -0,0 +1,96 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package filesystem + +import ( + "bytes" + "errors" + "os" + "time" +) + +// WriteFileWithRollback implements an atomic and durable file write operation with rollback. +// The rollback callback may be called by higher-level operations in case there is a need to +// revert changes as part of a more complex, multi-prong operation. +// Note that with or without rollback, WriteFileWithRollback does ensure disaster recovery. +func WriteFileWithRollback(filename string, data []byte, perm os.FileMode) (rollback func() error, err error) { + // Ensure there are no interrupted operations (leftover marker file and backup), or restore them if need be. + // If this is failing, we are dead in the water. + if err = ensureRecovery(filename); err != nil { + return nil, errors.Join(ErrFilesystemFailure, err) + } + + // On error, call recovery to rollback changes. + defer func() { + if err != nil { + err = errors.Join(ErrFilesystemFailure, err, ensureRecovery(filename)) + } + }() + + // If the file does not exist + markerData := "" + if _, err = os.Stat(filename); err != nil { + // Any error but does not exist is a hard error. + if !os.IsNotExist(err) { + return nil, err + } + // Otherwise, rollback and marker is "remove" + markerData = removeMarker + rollback = func() error { + return os.Remove(filename) + } + } else { + // Destination exists. + // Rollback will be: restore data from the backup + rollback = func() error { + return backupRestore(filename) + } + } + + // Make sure no leftover backup file is here + // Note: this happens after a successful write. Generally not a problem, except if the file is then deleted, + // then written to again, and that second write would fail. + _ = backupRemove(filename) + + // Create the marker. Failure to do so is a hard error. + if err = markerCreate(filename, markerData); err != nil { + return nil, err + } + + // If the file exists, we need to back it up. + if markerData == "" { + // Back it up now. Remove on failure. + if err = backupSave(filename); err != nil { + _ = backupRemove(filename) + _ = markerRemove(filename) + return nil, err + } + } + + // Now, write the content to the destination. + if err = fileWrite(bytes.NewReader(data), int64(len(data)), filename, perm, time.Time{}); err != nil { + return nil, err + } + + // Remove the marker. + if err = markerRemove(filename); err != nil { + return nil, err + } + + // On success, return the rollback + return rollback, nil +} diff --git a/pkg/internal/filesystem/writefile_rollback_test.go b/pkg/internal/filesystem/writefile_rollback_test.go new file mode 100644 index 00000000000..603d7538d14 --- /dev/null +++ b/pkg/internal/filesystem/writefile_rollback_test.go @@ -0,0 +1,206 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//nolint:forbidigo +package filesystem + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func TestRollbackForNonExistentFile(t *testing.T) { + // Test that calling the rollback after writing to a new existent file does remove the file + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "non-existent-file") + + // Write to it and check that this went through + rollback, err := WriteFileWithRollback(fp, []byte("new content"), 0o600) + assert.NilError(t, err) + cn, _ := os.ReadFile(fp) + assert.Equal(t, string(cn), "new content") + + // Roll it back and check it has been removed. + err = rollback() + assert.NilError(t, err) + _, err = os.ReadFile(fp) + assert.Assert(t, os.IsNotExist(err)) +} + +func TestRollbackForPreexistingFile(t *testing.T) { + // Test that calling the rollback after writing to a pre-existing file does restore the original + + // Create a file with pre-existing content + dir := t.TempDir() + fp := filepath.Join(dir, "pre-existing-file") + _ = os.WriteFile(fp, []byte("original content"), 0o600) + cn, _ := os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") + + // Write to it and check that this went through + rollback, err := WriteFileWithRollback(fp, []byte("updated content"), 0o600) + assert.NilError(t, err) + + cn, _ = os.ReadFile(fp) + assert.Equal(t, string(cn), "updated content") + + // Roll it back and check we have the original + err = rollback() + assert.NilError(t, err) + cn, _ = os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") +} + +func TestBackupFailure(t *testing.T) { + // Test that if backup is failing, a pre-existing file is restored to its original value. + + // Create a file with pre-existing content + dir := t.TempDir() + fp := filepath.Join(dir, "pre-existing-file") + _ = os.WriteFile(fp, []byte("original content"), 0o600) + cn, _ := os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") + + fakeError := errors.New("fake error") + // Override ioCopy to simulate an error creating the backup + ioCopy = func(dst io.Writer, src io.Reader) (written int64, err error) { + return 0, fakeError + } + + // Write. Check that we still have the original. + rollback, err := WriteFileWithRollback(fp, []byte("updated content"), 0o600) + assert.ErrorIs(t, err, fakeError) + assert.Assert(t, rollback == nil) + cn, _ = os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") +} + +func TestWriteFailure(t *testing.T) { + // Test that if write to a non-existent file is failing, the file is deleted. + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "non-existent-file") + + fakeError := errors.New("fake error") + // Override ioCopy to simulate an error while writing to the destination + // Note: since the file does not exist, there will be no backup + ioCopy = func(dst io.Writer, src io.Reader) (written int64, err error) { + return 0, fakeError + } + + // Write. Check that the file has been removed + rollback, err := WriteFileWithRollback(fp, []byte("update"), 0o600) + assert.ErrorIs(t, err, fakeError) + assert.Assert(t, rollback == nil) + _, err = os.ReadFile(fp) + assert.Assert(t, os.IsNotExist(err)) + + // Restore io copy + ioCopy = io.Copy +} + +func TestShortWriteFailure(t *testing.T) { + // Test that a write failing to write all content to a non-existent file will delete the file. + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "non-existent-file") + + // Override ioCopy to simulate a short write + ioCopy = func(dst io.Writer, src io.Reader) (written int64, err error) { + return 1, nil + } + + // Write. Check that we still have the original. + rollback, err := WriteFileWithRollback(fp, []byte("update"), 0o600) + assert.ErrorIs(t, err, io.ErrShortWrite) + assert.Assert(t, rollback == nil) + _, err = os.ReadFile(fp) + assert.Assert(t, os.IsNotExist(err)) + + // Restore io copy + ioCopy = io.Copy +} + +func TestDisasterRecoveryFromBackup(t *testing.T) { + // Test that a file that has left-over backup and marker will get restored to its original content + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "pre-existing-file") + _ = os.WriteFile(fp, []byte("original content"), 0o600) + + // Artificially create leftover marker + _ = markerCreate(fp, "") + // Artificially create leftover backup + _ = backupSave(fp) + + // Pork the file, to simulate interrupted write with leftover marker and backup + _ = os.WriteFile(fp, []byte("porked"), 0o600) + + // Now, see that disaster recovery got the backup + err := ensureRecovery(fp) + assert.NilError(t, err) + + cn, _ := os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") +} + +func TestDisasterRecoveryNoBackup1(t *testing.T) { + // Test that a previously non-existent file with a marker left-over will get deleted + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "non-existent-file") + + // Artificially create leftover marker + _ = markerCreate(fp, removeMarker) + + // Pork the file. mtime will be > marker mtime, meaning we expect the file to get deleted + _ = os.WriteFile(fp, []byte("porked"), 0o600) + + err := ensureRecovery(fp) + assert.NilError(t, err) + + _, err = os.ReadFile(fp) + assert.Assert(t, os.IsNotExist(err)) +} + +func TestDisasterRecoveryNoBackup2(t *testing.T) { + // Test that a file with a more recent marker leftover and no backup will be left untouched. + + // Create file + dir := t.TempDir() + fp := filepath.Join(dir, "pre-existing-file") + _ = os.WriteFile(fp, []byte("original content"), 0o600) + + // Artificially create leftover marker + _ = markerCreate(fp, "") + + err := ensureRecovery(fp) + assert.NilError(t, err) + + cn, _ := os.ReadFile(fp) + assert.Equal(t, string(cn), "original content") +} diff --git a/pkg/ipfs/image.go b/pkg/ipfs/image_ipfs.go similarity index 99% rename from pkg/ipfs/image.go rename to pkg/ipfs/image_ipfs.go index 84d73bda32e..6e8e49105e5 100644 --- a/pkg/ipfs/image.go +++ b/pkg/ipfs/image_ipfs.go @@ -1,3 +1,5 @@ +//go:build !no_ipfs + /* Copyright The containerd Authors. diff --git a/pkg/ipfs/image_noipfs.go b/pkg/ipfs/image_noipfs.go new file mode 100644 index 00000000000..43210f6e9df --- /dev/null +++ b/pkg/ipfs/image_noipfs.go @@ -0,0 +1,39 @@ +//go:build no_ipfs + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ipfs + +import ( + "context" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images/converter" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +// EnsureImage pull the specified image from IPFS. +func EnsureImage(ctx context.Context, client *containerd.Client, scheme, ref, ipfsPath string, options types.ImagePullOptions) (*imgutil.EnsuredImage, error) { + return nil, ErrNotImplemented +} + +// Push pushes the specified image to IPFS. +func Push(ctx context.Context, client *containerd.Client, rawRef string, layerConvert converter.ConvertFunc, allPlatforms bool, platform []string, ensureImage bool, ipfsPath string) (string, error) { + return "", ErrNotImplemented +} diff --git a/pkg/ipfs/noipfs.go b/pkg/ipfs/noipfs.go new file mode 100644 index 00000000000..4a1d29b0d3c --- /dev/null +++ b/pkg/ipfs/noipfs.go @@ -0,0 +1,25 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ipfs + +import ( + "fmt" + + "github.com/containerd/errdefs" +) + +var ErrNotImplemented = fmt.Errorf("%w: ipfs is disabled by the distributor of this build", errdefs.ErrNotImplemented) diff --git a/pkg/ipfs/registry.go b/pkg/ipfs/registry.go index 038b44f70ce..0e620bd2bfd 100644 --- a/pkg/ipfs/registry.go +++ b/pkg/ipfs/registry.go @@ -17,25 +17,7 @@ package ipfs import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "regexp" - "strconv" - "strings" "time" - - "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - - "github.com/containerd/containerd/v2/core/content" - "github.com/containerd/containerd/v2/core/images" - "github.com/containerd/log" - ipfsclient "github.com/containerd/stargz-snapshotter/ipfs/client" ) // RegistryOptions represents options to configure the registry. @@ -50,292 +32,3 @@ type RegistryOptions struct { // IpfsPath is the IPFS_PATH value to be used for ipfs command. IpfsPath string } - -func NewRegistry(options RegistryOptions) (http.Handler, error) { - // HTTP is only supported as of now. We can add https support here if needed (e.g. for connecting to it via proxy, etc) - iurl, err := ipfsclient.GetIPFSAPIAddress(lookupIPFSPath(options.IpfsPath), "http") - if err != nil { - return nil, err - } - return &server{options, ipfsclient.New(iurl)}, nil -} - -// server is a read-only registry which converts OCI Distribution Spec's pull-related API to IPFS -// https://github.com/opencontainers/distribution-spec/blob/v1.0/spec.md#pull -type server struct { - config RegistryOptions - ipfsclient *ipfsclient.Client -} - -var manifestRegexp = regexp.MustCompile(`/v2/ipfs/([a-z0-9]+)/manifests/(.*)`) -var blobsRegexp = regexp.MustCompile(`/v2/ipfs/([a-z0-9]+)/blobs/(.*)`) - -func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - cid, content, mediaType, size, err := s.serve(r) - if err != nil { - log.L.WithError(err).Warnf("failed to serve %q %q", r.Method, r.URL.Path) - // TODO: support response body following OCI Distribution Spec's error response format spec: - // https://github.com/opencontainers/distribution-spec/blob/v1.0/spec.md#error-codes - http.Error(w, "", http.StatusNotFound) - return - } - if content == nil { - log.L.Debugf("returning without contents") - w.WriteHeader(200) - return - } - w.Header().Set("Content-Type", mediaType) - w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) - if r.Method == "GET" { - http.ServeContent(w, r, "", time.Now(), content) - log.L.WithField("CID", cid).Debugf("served file") - } -} - -func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, error) { - if r.Method != "GET" && r.Method != "HEAD" { - return "", nil, "", 0, fmt.Errorf("unsupported method") - } - - if r.URL.Path == "/v2/" { - log.L.Debugf("requested /v2/") - return "", nil, "", 0, nil - } - - if matches := manifestRegexp.FindStringSubmatch(r.URL.Path); len(matches) != 0 { - cidStr, ref := matches[1], matches[2] - if _, dgstErr := digest.Parse(ref); dgstErr == nil { - resolvedCID, content, mediaType, size, err := s.serveContentByDigest(r.Context(), cidStr, ref) - if !images.IsManifestType(mediaType) && !images.IsIndexType(mediaType) { - return "", nil, "", 0, fmt.Errorf("cannot serve non-manifest from manifest API: %q", mediaType) - } - log.L.WithField("root CID", cidStr).WithField("digest", ref).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by digest") - return resolvedCID, content, mediaType, size, err - } - if ref != "latest" { - return "", nil, "", 0, fmt.Errorf("tag of %q must be latest but got %q", cidStr, ref) - } - resolvedCID, content, mediaType, size, err := s.serveContentByCID(r.Context(), cidStr) - if err != nil { - return "", nil, "", 0, err - } - log.L.WithField("root CID", cidStr).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by cid") - return resolvedCID, content, mediaType, size, nil - } - - if matches := blobsRegexp.FindStringSubmatch(r.URL.Path); len(matches) != 0 { - rootCIDStr, dgstStr := matches[1], matches[2] - resolvedCID, content, mediaType, size, err := s.serveContentByDigest(r.Context(), rootCIDStr, dgstStr) - if err != nil { - return "", nil, "", 0, err - } - log.L.WithField("root CID", rootCIDStr).WithField("digest", dgstStr).WithField("resolved CID", resolvedCID).Debugf("resolved blob by digest") - return resolvedCID, content, mediaType, size, nil - } - - return "", nil, "", 0, fmt.Errorf("unsupported path") -} - -func (s *server) serveContentByCID(ctx context.Context, targetCID string) (resC string, r io.ReadSeeker, mediaType string, size int64, err error) { - // TODO: make sure cidStr is a vaild CID? - c, desc, err := s.resolveCIDOfRootBlob(ctx, targetCID) - if err != nil { - return "", nil, "", 0, err - } - rc, err := s.getReadSeeker(ctx, c) - if err != nil { - return "", nil, "", 0, err - } - return c, rc, getMediaType(desc), desc.Size, nil -} - -func (s *server) serveContentByDigest(ctx context.Context, rootCID, digestStr string) (resC string, r io.ReadSeeker, mediaType string, size int64, err error) { - dgst, err := digest.Parse(digestStr) - if err != nil { - return "", nil, "", 0, err - } - _, rootDesc, err := s.resolveCIDOfRootBlob(ctx, rootCID) - if err != nil { - return "", nil, "", 0, err - } - targetCID, targetDesc, err := s.resolveCIDOfDigest(ctx, dgst, rootDesc) - if err != nil { - return "", nil, "", 0, err - } - rc, err := s.getReadSeeker(ctx, targetCID) - if err != nil { - return "", nil, "", 0, err - } - return targetCID, rc, getMediaType(targetDesc), targetDesc.Size, nil -} - -func (s *server) getReadSeeker(ctx context.Context, c string) (io.ReadSeeker, error) { - sr, err := s.getFile(ctx, c) - if err != nil { - return nil, err - } - return newBufReadSeeker(sr), nil -} - -func (s *server) getFile(ctx context.Context, c string) (*io.SectionReader, error) { - st, err := s.ipfsclient.StatCID(c) - if err != nil { - return nil, err - } - ra := &retryReaderAt{ - ctx: ctx, - readAtFunc: func(ctx context.Context, p []byte, off int64) (int, error) { - ofst, size := int(off), len(p) - r, err := s.ipfsclient.Get("/ipfs/"+c, &ofst, &size) - if err != nil { - return 0, err - } - return io.ReadFull(r, p) - }, - timeout: s.config.ReadTimeout, - retry: s.config.ReadRetryNum, - } - return io.NewSectionReader(ra, 0, int64(st.Size)), nil -} - -func (s *server) resolveCIDOfRootBlob(ctx context.Context, c string) (string, ocispec.Descriptor, error) { - rc, err := s.getReadSeeker(ctx, c) - if err != nil { - return "", ocispec.Descriptor{}, err - } - var desc ocispec.Descriptor - if err := json.NewDecoder(rc).Decode(&desc); err != nil { - return "", ocispec.Descriptor{}, err - } - c, err = getIPFSCID(desc) - if err != nil { - return "", ocispec.Descriptor{}, err - } - return c, desc, nil -} - -func (s *server) resolveCIDOfDigest(ctx context.Context, dgst digest.Digest, desc ocispec.Descriptor) (string, ocispec.Descriptor, error) { - c, err := getIPFSCID(desc) - if err != nil { - return "", ocispec.Descriptor{}, err - } - if desc.Digest == dgst { - return c, desc, nil // hit - } - if !images.IsManifestType(desc.MediaType) && !images.IsIndexType(desc.MediaType) { - // This is not the target blob and have no child. Early return here and avoid querying this blob. - return "", ocispec.Descriptor{}, fmt.Errorf("blob doesn't match") - } - sr, err := s.getFile(ctx, c) - if err != nil { - return "", ocispec.Descriptor{}, err - } - descs, err := images.Children(ctx, &readerProvider{desc, sr}, desc) - if err != nil { - return "", ocispec.Descriptor{}, err - } - var errs []error - for _, desc := range descs { - gotCID, gotDesc, err := s.resolveCIDOfDigest(ctx, dgst, desc) - if err != nil { - errs = append(errs, err) - continue - } - return gotCID, gotDesc, nil - } - allErr := errors.Join(errs...) - if allErr == nil { - return "", ocispec.Descriptor{}, fmt.Errorf("not found") - } - return "", ocispec.Descriptor{}, allErr -} - -func getIPFSCID(desc ocispec.Descriptor) (string, error) { - for _, u := range desc.URLs { - if strings.HasPrefix(u, "ipfs://") { - // support only content addressable URL (ipfs://) - return u[7:], nil - } - } - return "", fmt.Errorf("no CID is recorded in %s", desc.Digest) -} - -func getMediaType(desc ocispec.Descriptor) string { - if images.IsManifestType(desc.MediaType) || images.IsIndexType(desc.MediaType) || images.IsConfigType(desc.MediaType) { - return desc.MediaType - } - return "application/octet-stream" -} - -type retryReaderAt struct { - ctx context.Context - readAtFunc func(ctx context.Context, p []byte, off int64) (int, error) - timeout time.Duration - retry int -} - -func (r *retryReaderAt) ReadAt(p []byte, off int64) (int, error) { - if r.retry < 0 { - r.retry = 0 - } - for i := 0; i <= r.retry; i++ { - ctx := r.ctx - if r.timeout != 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, r.timeout) - defer cancel() - } - n, err := r.readAtFunc(ctx, p, off) - if err == nil { - return n, nil - } else if !errors.Is(err, context.DeadlineExceeded) { - return 0, err - } - // deadline exceeded. retry. - } - return 0, context.DeadlineExceeded -} - -func newBufReadSeeker(rs io.ReadSeeker) io.ReadSeeker { - rsc := &bufReadSeeker{ - rs: rs, - } - rsc.curR = bufio.NewReaderSize(rsc.rs, 512*1024) - return rsc -} - -type bufReadSeeker struct { - rs io.ReadSeeker - curR *bufio.Reader -} - -func (r *bufReadSeeker) Read(p []byte) (int, error) { - return r.curR.Read(p) -} - -func (r *bufReadSeeker) Seek(offset int64, whence int) (int64, error) { - n, err := r.rs.Seek(offset, whence) - if err != nil { - return 0, err - } - r.curR.Reset(r.rs) - return n, nil -} - -type readerProvider struct { - desc ocispec.Descriptor - r *io.SectionReader -} - -func (p *readerProvider) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { - if desc.Digest != p.desc.Digest || desc.Size != p.desc.Size { - return nil, fmt.Errorf("unexpected content") - } - return &contentReaderAt{p.r}, nil -} - -type contentReaderAt struct { - *io.SectionReader -} - -func (r *contentReaderAt) Close() error { return nil } diff --git a/pkg/ipfs/registry_ipfs.go b/pkg/ipfs/registry_ipfs.go new file mode 100644 index 00000000000..915947310a2 --- /dev/null +++ b/pkg/ipfs/registry_ipfs.go @@ -0,0 +1,330 @@ +//go:build !no_ipfs + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ipfs + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + ipfsclient "github.com/containerd/stargz-snapshotter/ipfs/client" +) + +func NewRegistry(options RegistryOptions) (http.Handler, error) { + // HTTP is only supported as of now. We can add https support here if needed (e.g. for connecting to it via proxy, etc) + iurl, err := ipfsclient.GetIPFSAPIAddress(lookupIPFSPath(options.IpfsPath), "http") + if err != nil { + return nil, err + } + return &server{options, ipfsclient.New(iurl)}, nil +} + +// server is a read-only registry which converts OCI Distribution Spec's pull-related API to IPFS +// https://github.com/opencontainers/distribution-spec/blob/v1.0/spec.md#pull +type server struct { + config RegistryOptions + ipfsclient *ipfsclient.Client +} + +var manifestRegexp = regexp.MustCompile(`/v2/ipfs/([a-z0-9]+)/manifests/(.*)`) +var blobsRegexp = regexp.MustCompile(`/v2/ipfs/([a-z0-9]+)/blobs/(.*)`) + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + cid, content, mediaType, size, err := s.serve(r) + if err != nil { + log.L.WithError(err).Warnf("failed to serve %q %q", r.Method, r.URL.Path) + // TODO: support response body following OCI Distribution Spec's error response format spec: + // https://github.com/opencontainers/distribution-spec/blob/v1.0/spec.md#error-codes + http.Error(w, "", http.StatusNotFound) + return + } + if content == nil { + log.L.Debugf("returning without contents") + w.WriteHeader(200) + return + } + w.Header().Set("Content-Type", mediaType) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + if r.Method == "GET" { + http.ServeContent(w, r, "", time.Now(), content) + log.L.WithField("CID", cid).Debugf("served file") + } +} + +func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, error) { + if r.Method != "GET" && r.Method != "HEAD" { + return "", nil, "", 0, fmt.Errorf("unsupported method") + } + + if r.URL.Path == "/v2/" { + log.L.Debugf("requested /v2/") + return "", nil, "", 0, nil + } + + if matches := manifestRegexp.FindStringSubmatch(r.URL.Path); len(matches) != 0 { + cidStr, ref := matches[1], matches[2] + if _, dgstErr := digest.Parse(ref); dgstErr == nil { + resolvedCID, content, mediaType, size, err := s.serveContentByDigest(r.Context(), cidStr, ref) + if !images.IsManifestType(mediaType) && !images.IsIndexType(mediaType) { + return "", nil, "", 0, fmt.Errorf("cannot serve non-manifest from manifest API: %q", mediaType) + } + log.L.WithField("root CID", cidStr).WithField("digest", ref).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by digest") + return resolvedCID, content, mediaType, size, err + } + if ref != "latest" { + return "", nil, "", 0, fmt.Errorf("tag of %q must be latest but got %q", cidStr, ref) + } + resolvedCID, content, mediaType, size, err := s.serveContentByCID(r.Context(), cidStr) + if err != nil { + return "", nil, "", 0, err + } + log.L.WithField("root CID", cidStr).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by cid") + return resolvedCID, content, mediaType, size, nil + } + + if matches := blobsRegexp.FindStringSubmatch(r.URL.Path); len(matches) != 0 { + rootCIDStr, dgstStr := matches[1], matches[2] + resolvedCID, content, mediaType, size, err := s.serveContentByDigest(r.Context(), rootCIDStr, dgstStr) + if err != nil { + return "", nil, "", 0, err + } + log.L.WithField("root CID", rootCIDStr).WithField("digest", dgstStr).WithField("resolved CID", resolvedCID).Debugf("resolved blob by digest") + return resolvedCID, content, mediaType, size, nil + } + + return "", nil, "", 0, fmt.Errorf("unsupported path") +} + +func (s *server) serveContentByCID(ctx context.Context, targetCID string) (resC string, r io.ReadSeeker, mediaType string, size int64, err error) { + // TODO: make sure cidStr is a vaild CID? + c, desc, err := s.resolveCIDOfRootBlob(ctx, targetCID) + if err != nil { + return "", nil, "", 0, err + } + rc, err := s.getReadSeeker(ctx, c) + if err != nil { + return "", nil, "", 0, err + } + return c, rc, getMediaType(desc), desc.Size, nil +} + +func (s *server) serveContentByDigest(ctx context.Context, rootCID, digestStr string) (resC string, r io.ReadSeeker, mediaType string, size int64, err error) { + dgst, err := digest.Parse(digestStr) + if err != nil { + return "", nil, "", 0, err + } + _, rootDesc, err := s.resolveCIDOfRootBlob(ctx, rootCID) + if err != nil { + return "", nil, "", 0, err + } + targetCID, targetDesc, err := s.resolveCIDOfDigest(ctx, dgst, rootDesc) + if err != nil { + return "", nil, "", 0, err + } + rc, err := s.getReadSeeker(ctx, targetCID) + if err != nil { + return "", nil, "", 0, err + } + return targetCID, rc, getMediaType(targetDesc), targetDesc.Size, nil +} + +func (s *server) getReadSeeker(ctx context.Context, c string) (io.ReadSeeker, error) { + sr, err := s.getFile(ctx, c) + if err != nil { + return nil, err + } + return newBufReadSeeker(sr), nil +} + +func (s *server) getFile(ctx context.Context, c string) (*io.SectionReader, error) { + st, err := s.ipfsclient.StatCID(c) + if err != nil { + return nil, err + } + ra := &retryReaderAt{ + ctx: ctx, + readAtFunc: func(ctx context.Context, p []byte, off int64) (int, error) { + ofst, size := int(off), len(p) + r, err := s.ipfsclient.Get("/ipfs/"+c, &ofst, &size) + if err != nil { + return 0, err + } + return io.ReadFull(r, p) + }, + timeout: s.config.ReadTimeout, + retry: s.config.ReadRetryNum, + } + return io.NewSectionReader(ra, 0, int64(st.Size)), nil +} + +func (s *server) resolveCIDOfRootBlob(ctx context.Context, c string) (string, ocispec.Descriptor, error) { + rc, err := s.getReadSeeker(ctx, c) + if err != nil { + return "", ocispec.Descriptor{}, err + } + var desc ocispec.Descriptor + if err := json.NewDecoder(rc).Decode(&desc); err != nil { + return "", ocispec.Descriptor{}, err + } + c, err = getIPFSCID(desc) + if err != nil { + return "", ocispec.Descriptor{}, err + } + return c, desc, nil +} + +func (s *server) resolveCIDOfDigest(ctx context.Context, dgst digest.Digest, desc ocispec.Descriptor) (string, ocispec.Descriptor, error) { + c, err := getIPFSCID(desc) + if err != nil { + return "", ocispec.Descriptor{}, err + } + if desc.Digest == dgst { + return c, desc, nil // hit + } + if !images.IsManifestType(desc.MediaType) && !images.IsIndexType(desc.MediaType) { + // This is not the target blob and have no child. Early return here and avoid querying this blob. + return "", ocispec.Descriptor{}, fmt.Errorf("blob doesn't match") + } + sr, err := s.getFile(ctx, c) + if err != nil { + return "", ocispec.Descriptor{}, err + } + descs, err := images.Children(ctx, &readerProvider{desc, sr}, desc) + if err != nil { + return "", ocispec.Descriptor{}, err + } + var errs []error + for _, desc := range descs { + gotCID, gotDesc, err := s.resolveCIDOfDigest(ctx, dgst, desc) + if err != nil { + errs = append(errs, err) + continue + } + return gotCID, gotDesc, nil + } + allErr := errors.Join(errs...) + if allErr == nil { + return "", ocispec.Descriptor{}, fmt.Errorf("not found") + } + return "", ocispec.Descriptor{}, allErr +} + +func getIPFSCID(desc ocispec.Descriptor) (string, error) { + for _, u := range desc.URLs { + if strings.HasPrefix(u, "ipfs://") { + // support only content addressable URL (ipfs://) + return u[7:], nil + } + } + return "", fmt.Errorf("no CID is recorded in %s", desc.Digest) +} + +func getMediaType(desc ocispec.Descriptor) string { + if images.IsManifestType(desc.MediaType) || images.IsIndexType(desc.MediaType) || images.IsConfigType(desc.MediaType) { + return desc.MediaType + } + return "application/octet-stream" +} + +type retryReaderAt struct { + ctx context.Context + readAtFunc func(ctx context.Context, p []byte, off int64) (int, error) + timeout time.Duration + retry int +} + +func (r *retryReaderAt) ReadAt(p []byte, off int64) (int, error) { + if r.retry < 0 { + r.retry = 0 + } + for i := 0; i <= r.retry; i++ { + ctx := r.ctx + if r.timeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.timeout) + defer cancel() + } + n, err := r.readAtFunc(ctx, p, off) + if err == nil { + return n, nil + } else if !errors.Is(err, context.DeadlineExceeded) { + return 0, err + } + // deadline exceeded. retry. + } + return 0, context.DeadlineExceeded +} + +func newBufReadSeeker(rs io.ReadSeeker) io.ReadSeeker { + rsc := &bufReadSeeker{ + rs: rs, + } + rsc.curR = bufio.NewReaderSize(rsc.rs, 512*1024) + return rsc +} + +type bufReadSeeker struct { + rs io.ReadSeeker + curR *bufio.Reader +} + +func (r *bufReadSeeker) Read(p []byte) (int, error) { + return r.curR.Read(p) +} + +func (r *bufReadSeeker) Seek(offset int64, whence int) (int64, error) { + n, err := r.rs.Seek(offset, whence) + if err != nil { + return 0, err + } + r.curR.Reset(r.rs) + return n, nil +} + +type readerProvider struct { + desc ocispec.Descriptor + r *io.SectionReader +} + +func (p *readerProvider) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { + if desc.Digest != p.desc.Digest || desc.Size != p.desc.Size { + return nil, fmt.Errorf("unexpected content") + } + return &contentReaderAt{p.r}, nil +} + +type contentReaderAt struct { + *io.SectionReader +} + +func (r *contentReaderAt) Close() error { return nil } diff --git a/pkg/ipfs/registry_noipfs.go b/pkg/ipfs/registry_noipfs.go new file mode 100644 index 00000000000..f93c114b9d6 --- /dev/null +++ b/pkg/ipfs/registry_noipfs.go @@ -0,0 +1,27 @@ +//go:build no_ipfs + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package ipfs + +import ( + "net/http" +) + +func NewRegistry(options RegistryOptions) (http.Handler, error) { + return nil, ErrNotImplemented +} diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 9356ad03a68..e0838d6ea9c 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -41,6 +41,9 @@ const ( //Compose Volume Name ComposeVolume = "com.docker.compose.volume" + // ComposeConfigHash stores the service configuration hash used for convergence decisions + ComposeConfigHash = "com.docker.compose.config-hash" + // Hostname Hostname = Prefix + "hostname" @@ -57,6 +60,7 @@ const ( // Currently, the length of the slice must be 1. Networks = Prefix + "networks" + // DEPRECATED : https://github.com/containerd/nerdctl/pull/4290 // Ports is a JSON-marshalled string of []cni.PortMapping . Ports = Prefix + "ports" @@ -118,4 +122,10 @@ const ( // User is the username of the container User = Prefix + "user" + + // HealthCheck stores the health check configuration used to run health checks on the container + HealthCheck = Prefix + "healthcheck" + + // HealthState stores the current health state (status and failing streak). + HealthState = Prefix + "healthstate" ) diff --git a/pkg/lockutil/lockutil_unix.go b/pkg/lockutil/lockutil_unix.go deleted file mode 100644 index c4655c58b6c..00000000000 --- a/pkg/lockutil/lockutil_unix.go +++ /dev/null @@ -1,78 +0,0 @@ -//go:build unix - -/* - Copyright The containerd Authors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package lockutil - -import ( - "fmt" - "os" - - "golang.org/x/sys/unix" - - "github.com/containerd/log" -) - -func WithDirLock(dir string, fn func() error) error { - _ = os.MkdirAll(dir, 0700) - dirFile, err := os.Open(dir) - if err != nil { - return err - } - defer dirFile.Close() - if err := flock(dirFile, unix.LOCK_EX); err != nil { - return fmt.Errorf("failed to lock %q: %w", dir, err) - } - defer func() { - if err := flock(dirFile, unix.LOCK_UN); err != nil { - log.L.WithError(err).Errorf("failed to unlock %q", dir) - } - }() - return fn() -} - -func flock(f *os.File, flags int) error { - fd := int(f.Fd()) - for { - err := unix.Flock(fd, flags) - if err == nil || err != unix.EINTR { - return err - } - } -} - -func Lock(dir string) (*os.File, error) { - _ = os.MkdirAll(dir, 0700) - dirFile, err := os.Open(dir) - if err != nil { - return nil, err - } - - if err = flock(dirFile, unix.LOCK_EX); err != nil { - return nil, err - } - - return dirFile, nil -} - -func Unlock(locked *os.File) error { - defer func() { - _ = locked.Close() - }() - - return flock(locked, unix.LOCK_UN) -} diff --git a/pkg/lockutil/lockutil_windows.go b/pkg/lockutil/lockutil_windows.go deleted file mode 100644 index 205efde83f5..00000000000 --- a/pkg/lockutil/lockutil_windows.go +++ /dev/null @@ -1,65 +0,0 @@ -/* - Copyright The containerd Authors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package lockutil - -import ( - "fmt" - "os" - - "golang.org/x/sys/windows" - - "github.com/containerd/log" -) - -func WithDirLock(dir string, fn func() error) error { - dirFile, err := os.OpenFile(dir+".lock", os.O_CREATE, 0644) - if err != nil { - return err - } - defer dirFile.Close() - // see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203(v=vs.85).aspx - if err = windows.LockFileEx(windows.Handle(dirFile.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, ^uint32(0), ^uint32(0), new(windows.Overlapped)); err != nil { - return fmt.Errorf("failed to lock %q: %w", dir, err) - } - - defer func() { - if err := windows.UnlockFileEx(windows.Handle(dirFile.Fd()), 0, ^uint32(0), ^uint32(0), new(windows.Overlapped)); err != nil { - log.L.WithError(err).Errorf("failed to unlock %q", dir) - } - }() - return fn() -} - -func Lock(dir string) (*os.File, error) { - dirFile, err := os.OpenFile(dir+".lock", os.O_CREATE, 0644) - if err != nil { - return nil, err - } - // see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203(v=vs.85).aspx - if err = windows.LockFileEx(windows.Handle(dirFile.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, ^uint32(0), ^uint32(0), new(windows.Overlapped)); err != nil { - return nil, fmt.Errorf("failed to lock %q: %w", dir, err) - } - return dirFile, nil -} - -func Unlock(locked *os.File) error { - defer func() { - _ = locked.Close() - }() - - return windows.UnlockFileEx(windows.Handle(locked.Fd()), 0, ^uint32(0), ^uint32(0), new(windows.Overlapped)) -} diff --git a/pkg/logging/cri_logger_test.go b/pkg/logging/cri_logger_test.go index 6d45e4999bc..01f16e43840 100644 --- a/pkg/logging/cri_logger_test.go +++ b/pkg/logging/cri_logger_test.go @@ -184,12 +184,12 @@ func TestReadLogsLimitsWithTimestamps(t *testing.T) { count := 10000 for i := 0; i < count; i++ { - tmpfile.WriteString(fmt.Sprintf(logLineFmt, i)) + fmt.Fprintf(tmpfile, logLineFmt, i) } tmpfile.WriteString(logLineNewLine) for i := 0; i < count; i++ { - tmpfile.WriteString(fmt.Sprintf(logLineFmt, i)) + fmt.Fprintf(tmpfile, logLineFmt, i) } tmpfile.WriteString(logLineNewLine) @@ -271,11 +271,10 @@ func TestReadRotatedLog(t *testing.T) { // Write the first three lines to log file now := time.Now().Format(time.RFC3339Nano) if line%2 == 0 { - file.WriteString(fmt.Sprintf( - "%s stdout P line%d\n", now, line)) + fmt.Fprintf(file, "%s stdout P line%d\n", now, line) + } else { - file.WriteString(fmt.Sprintf( - "%s stderr P line%d\n", now, line)) + fmt.Fprintf(file, "%s stderr P line%d\n", now, line) } time.Sleep(1 * time.Millisecond) diff --git a/pkg/logging/json_logger.go b/pkg/logging/json_logger.go index d715d0158ee..7e2dca196fd 100644 --- a/pkg/logging/json_logger.go +++ b/pkg/logging/json_logger.go @@ -33,6 +33,7 @@ import ( "github.com/containerd/containerd/v2/core/runtime/v2/logging" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/logging/jsonfile" "github.com/containerd/nerdctl/v2/pkg/logging/tail" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -72,7 +73,7 @@ func (jsonLogger *JSONLogger) Init(dataStore, ns, id string) error { return err } if _, err := os.Stat(jsonFilePath); errors.Is(err, os.ErrNotExist) { - if writeErr := os.WriteFile(jsonFilePath, []byte{}, 0600); writeErr != nil { + if writeErr := filesystem.WriteFile(jsonFilePath, []byte{}, 0600); writeErr != nil { return writeErr } } diff --git a/pkg/logging/json_logger_test.go b/pkg/logging/json_logger_test.go index 7d0be36285d..7b41c10a68c 100644 --- a/pkg/logging/json_logger_test.go +++ b/pkg/logging/json_logger_test.go @@ -73,6 +73,7 @@ func TestReadRotatedJSONLog(t *testing.T) { time.Sleep(1 * time.Millisecond) logData, _ := json.Marshal(log) file.Write(logData) + file.Write([]byte("\n")) if line == 5 { file.Close() @@ -104,7 +105,7 @@ func TestReadRotatedJSONLog(t *testing.T) { close(containerStopped) if expectedStdout != stdoutBuf.String() { - t.Errorf("expected: %s, acoutal: %s", expectedStdout, stdoutBuf.String()) + t.Errorf("expected: %s, actual: %s", expectedStdout, stdoutBuf.String()) } } diff --git a/pkg/logging/jsonfile/jsonfile.go b/pkg/logging/jsonfile/jsonfile.go index a1693cda0d7..6e6f7984eda 100644 --- a/pkg/logging/jsonfile/jsonfile.go +++ b/pkg/logging/jsonfile/jsonfile.go @@ -54,7 +54,7 @@ func Encode(stdout <-chan string, stderr <-chan string, writer io.Writer) error Stream: name, } for logEntry := range dataChan { - e.Log = logEntry + "\n" + e.Log = logEntry e.Time = time.Now().UTC() encMu.Lock() encErr := enc.Encode(e) diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index eab10cbd726..91a3231ee3a 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -38,7 +38,7 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/log" - "github.com/containerd/nerdctl/v2/pkg/lockutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) const ( @@ -142,7 +142,7 @@ func LoadLogConfig(dataStore, ns, id string) (LogConfig, error) { logConfig := LogConfig{} logConfigFilePath := LogConfigFilePath(dataStore, ns, id) - logConfigData, err := os.ReadFile(logConfigFilePath) + logConfigData, err := filesystem.ReadFile(logConfigFilePath) if err != nil { return logConfig, fmt.Errorf("failed to read log config file %q: %w", logConfigFilePath, err) } @@ -160,7 +160,7 @@ func getLockPath(dataStore, ns, id string) string { // WaitForLogger waits until the logger has finished executing and processing container logs func WaitForLogger(dataStore, ns, id string) error { - return lockutil.WithDirLock(getLockPath(dataStore, ns, id), func() error { + return filesystem.WithLock(getLockPath(dataStore, ns, id), func() error { return nil }) } @@ -255,7 +255,7 @@ func loggingProcessAdapter(ctx context.Context, driver Driver, dataStore, addres var s string s, err = r.ReadString('\n') if len(s) > 0 { - dataChan <- strings.TrimSuffix(s, "\n") + dataChan <- s } if err != nil && err != io.EOF { @@ -305,16 +305,11 @@ func loggerFunc(dataStore string) (logging.LoggerFunc, error) { } loggerLock := getLockPath(dataStore, config.Namespace, config.ID) - f, err := os.Create(loggerLock) - if err != nil { - return err - } - defer f.Close() // the logger will obtain an exclusive lock on a file until the container is // stopped and the driver has finished processing all output, // so that waiting log viewers can be signalled when the process is complete. - return lockutil.WithDirLock(loggerLock, func() error { + return filesystem.WithLock(loggerLock, func() error { if err := ready(); err != nil { return err } diff --git a/pkg/manifeststore/manifeststore.go b/pkg/manifeststore/manifeststore.go new file mode 100644 index 00000000000..252c74a8f0f --- /dev/null +++ b/pkg/manifeststore/manifeststore.go @@ -0,0 +1,132 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifeststore + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/store" +) + +type Store interface { + Get(listRef *referenceutil.ImageReference, manifestRef *referenceutil.ImageReference) (*manifesttypes.DockerManifestEntry, error) + // GetList returns all the local manifests for a index or manifest list + GetList(listRef *referenceutil.ImageReference) ([]*manifesttypes.DockerManifestEntry, error) + // Save saves a manifest as part of a index or local manifest list + Save(listRef, manifestRef *referenceutil.ImageReference, manifest *manifesttypes.DockerManifestEntry) error + // Remove removes a index or local manifest list + Remove(listRef *referenceutil.ImageReference) error +} + +type manifestStore struct { + store store.Store +} + +func NewStore(dataRoot string) (Store, error) { + manifestRoot := filepath.Join(dataRoot, "manifests") + st, err := store.New(manifestRoot, 0o755, 0o644) + if err != nil { + return nil, fmt.Errorf("failed to create manifest store: %w", err) + } + return &manifestStore{store: st}, nil +} + +func (s *manifestStore) Get(listRef *referenceutil.ImageReference, manifestRef *referenceutil.ImageReference) (*manifesttypes.DockerManifestEntry, error) { + var manifest *manifesttypes.DockerManifestEntry + err := s.store.WithLock(func() error { + listPath := makeFilesafeName(listRef.String()) + manifestPath := makeFilesafeName(manifestRef.String()) + + var err error + manifest, err = s.getManifestFromPath(listPath, manifestPath) + return err + }) + return manifest, err +} + +func (s *manifestStore) GetList(listRef *referenceutil.ImageReference) ([]*manifesttypes.DockerManifestEntry, error) { + listPath := makeFilesafeName(listRef.String()) + + if err := s.store.Lock(); err != nil { + return nil, err + } + defer s.store.Release() + + manifestPaths, err := s.store.List(listPath) + if err != nil { + return nil, err + } + + var manifests []*manifesttypes.DockerManifestEntry + for _, manifestPath := range manifestPaths { + manifest, err := s.getManifestFromPath(listPath, manifestPath) + if err != nil { + return nil, err + } + manifests = append(manifests, manifest) + } + + return manifests, nil +} + +func (s *manifestStore) Save(listRef, manifestRef *referenceutil.ImageReference, manifest *manifesttypes.DockerManifestEntry) error { + return s.store.WithLock(func() error { + listPath := makeFilesafeName(listRef.String()) + if err := s.store.GroupEnsure(listPath); err != nil { + return err + } + + manifestPath := makeFilesafeName(manifestRef.String()) + data, err := json.Marshal(manifest) + if err != nil { + return err + } + + return s.store.Set(data, listPath, manifestPath) + }) +} + +func (s *manifestStore) Remove(listRef *referenceutil.ImageReference) error { + return s.store.WithLock(func() error { + listPath := makeFilesafeName(listRef.String()) + return s.store.Delete(listPath) + }) +} + +func (s *manifestStore) getManifestFromPath(listPath, manifestPath string) (*manifesttypes.DockerManifestEntry, error) { + data, err := s.store.Get(listPath, manifestPath) + if err != nil { + return nil, err + } + + var manifest manifesttypes.DockerManifestEntry + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + return &manifest, nil +} + +func makeFilesafeName(ref string) string { + fileName := strings.ReplaceAll(ref, ":", "-") + return strings.ReplaceAll(fileName, "/", "_") +} diff --git a/pkg/manifesttypes/manifesttypes.go b/pkg/manifesttypes/manifesttypes.go new file mode 100644 index 00000000000..7e43b383c54 --- /dev/null +++ b/pkg/manifesttypes/manifesttypes.go @@ -0,0 +1,68 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifesttypes + +import ( + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// For Docker's verbose format +type ( + // DockerManifestEntry represents a single manifest entry in Docker's verbose format + DockerManifestEntry struct { + Ref string `json:"Ref"` + Descriptor ocispec.Descriptor `json:"Descriptor"` + Raw string `json:"Raw"` + SchemaV2Manifest interface{} `json:"SchemaV2Manifest,omitempty"` + OCIManifest interface{} `json:"OCIManifest,omitempty"` + } + + ManifestStruct struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config ocispec.Descriptor `json:"config"` + Layers []ocispec.Descriptor `json:"layers"` + Annotations map[string]string `json:"annotations,omitempty"` + } + + DockerManifestListStruct struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []ocispec.Descriptor `json:"manifests"` + } + + DockerManifestStruct = ManifestStruct + OCIManifestStruct = ManifestStruct + OCIIndexStruct = ocispec.Index +) + +// For manifest push, compatible with Docker distribution spec +type ( + DockerManifestDescriptor struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest digest.Digest `json:"digest"` + Platform ocispec.Platform `json:"platform"` + } + + DockerManifestList struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType,omitempty"` + Manifests []DockerManifestDescriptor `json:"manifests"` + } +) diff --git a/pkg/manifestutil/manifestutils.go b/pkg/manifestutil/manifestutils.go new file mode 100644 index 00000000000..acf4e385db1 --- /dev/null +++ b/pkg/manifestutil/manifestutils.go @@ -0,0 +1,276 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manifestutil + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/manifesttypes" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +// manifestParser defines a function type for parsing manifest data +type manifestParser func([]byte) (interface{}, error) + +// manifestParsers maps media types to their parsing functions +var manifestParsers = map[string]manifestParser{ + ocispec.MediaTypeImageManifest: parseOCIManifest, + images.MediaTypeDockerSchema2Manifest: parseDockerManifest, + images.MediaTypeDockerSchema2ManifestList: parseDockerManifestList, + ocispec.MediaTypeImageIndex: parseOCIIndex, +} + +// NoSuchManifestError represents an error when a manifest is not found +type NoSuchManifestError struct { + Ref string +} + +func (e *NoSuchManifestError) Error() string { + return fmt.Sprintf("No such manifest: %s", e.Ref) +} + +// NewNoSuchManifestError creates a new NoSuchManifestError +func NewNoSuchManifestError(ref string) error { + return &NoSuchManifestError{Ref: ref} +} + +// ParseManifest parses manifest data based on media type +func ParseManifest(mediaType string, data []byte) (interface{}, error) { + if parser, exists := manifestParsers[mediaType]; exists { + return parser(data) + } + return nil, fmt.Errorf("unsupported media type: %s", mediaType) +} + +// parseOCIManifest parses OCI manifest data +func parseOCIManifest(data []byte) (interface{}, error) { + var ociManifest manifesttypes.OCIManifestStruct + if err := json.Unmarshal(data, &ociManifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + return ociManifest, nil +} + +// parseDockerManifest parses Docker manifest data +func parseDockerManifest(data []byte) (interface{}, error) { + var dockerManifest manifesttypes.DockerManifestStruct + if err := json.Unmarshal(data, &dockerManifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal docker manifest: %w", err) + } + return dockerManifest, nil +} + +// parseDockerManifestList parses Docker manifest list data +func parseDockerManifestList(data []byte) (interface{}, error) { + var manifestList manifesttypes.DockerManifestListStruct + if err := json.Unmarshal(data, &manifestList); err != nil { + return nil, fmt.Errorf("failed to unmarshal docker index: %w", err) + } + return manifestList, nil +} + +// parseOCIIndex parses OCI index data +func parseOCIIndex(data []byte) (interface{}, error) { + var index manifesttypes.OCIIndexStruct + if err := json.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("failed to unmarshal index: %w", err) + } + return index, nil +} + +// CreateResolver creates a resolver for registry operations +func CreateResolver(ctx context.Context, domain string, globalOptions types.GlobalCommandOptions, insecure bool) (remotes.Resolver, error) { + dOpts := buildResolverOptions(globalOptions, insecure) + + resolver, err := dockerconfigresolver.New(ctx, domain, dOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create resolver: %w", err) + } + + return resolver, nil +} + +// buildResolverOptions builds resolver options based on global options and security settings +func buildResolverOptions(globalOptions types.GlobalCommandOptions, insecure bool) []dockerconfigresolver.Opt { + var dOpts []dockerconfigresolver.Opt + + if insecure { + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(globalOptions.HostsDir)) + + return dOpts +} + +// FetchManifestData fetches manifest descriptor and data from the registry +func FetchManifestData(ctx context.Context, resolver remotes.Resolver, ref string) (ocispec.Descriptor, []byte, error) { + _, desc, err := resolver.Resolve(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to resolve %s: %w", ref, err) + } + + fetcher, err := resolver.Fetcher(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to create fetcher: %w", err) + } + + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return ocispec.Descriptor{}, nil, fmt.Errorf("failed to read manifest data: %w", err) + } + + return desc, data, nil +} + +// GetManifest returns manifest, descriptor, and raw data in one call +func GetManifest(ctx context.Context, parsedRef *referenceutil.ImageReference, globalOptions types.GlobalCommandOptions, insecure bool) (interface{}, ocispec.Descriptor, []byte, error) { + resolver, err := CreateResolver(ctx, parsedRef.Domain, globalOptions, insecure) + if err != nil { + return nil, ocispec.Descriptor{}, nil, fmt.Errorf("failed to create resolver: %w", err) + } + + desc, data, err := FetchManifestData(ctx, resolver, parsedRef.String()) + if err != nil { + return nil, ocispec.Descriptor{}, nil, err + } + + manifest, err := ParseManifest(desc.MediaType, data) + if err != nil { + return nil, ocispec.Descriptor{}, nil, err + } + + return manifest, desc, data, nil +} + +// getManifestFieldName returns the appropriate field name based on media type +func getManifestFieldName(mediaType string) string { + switch mediaType { + case images.MediaTypeDockerSchema2Manifest: + return "SchemaV2Manifest" + case ocispec.MediaTypeImageManifest: + return "OCIManifest" + default: + return "ManifestStruct" + } +} + +// CreateManifestEntry creates a DockerManifestEntry with proper ManifestStruct +func CreateManifestEntry(parsedRef *referenceutil.ImageReference, desc ocispec.Descriptor, rawData []byte) (manifesttypes.DockerManifestEntry, error) { + var ref string + if parsedRef.Digest != "" { + ref = parsedRef.String() + } else { + ref = fmt.Sprintf("%s@%s", parsedRef.String(), desc.Digest.String()) + } + + entry := manifesttypes.DockerManifestEntry{ + Ref: ref, + Descriptor: desc, + Raw: base64.StdEncoding.EncodeToString(rawData), + } + + manifest, err := ParseManifest(desc.MediaType, rawData) + if err != nil { + return manifesttypes.DockerManifestEntry{}, fmt.Errorf("failed to parse manifest: %w", err) + } + + fieldName := getManifestFieldName(desc.MediaType) + switch fieldName { + case "SchemaV2Manifest": + entry.SchemaV2Manifest = manifest + case "OCIManifest": + entry.OCIManifest = manifest + } + + // Special handling for OCI manifests to match Docker output + if desc.MediaType == ocispec.MediaTypeImageManifest { + entry.Descriptor.Annotations = nil + } + + return entry, nil +} + +// getPlatformFromConfig return platform information from the config blob +func getPlatformFromConfig(ctx context.Context, resolver remotes.Resolver, ref string, configDesc ocispec.Descriptor) (*ocispec.Platform, error) { + fetcher, err := resolver.Fetcher(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to create fetcher: %w", err) + } + + rc, err := fetcher.Fetch(ctx, configDesc) + if err != nil { + return nil, fmt.Errorf("failed to fetch config: %w", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("failed to read config data: %w", err) + } + + var config ocispec.Image + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + return &config.Platform, nil + +} + +// GetPlatform return the platform information from manifest config +func GetPlatform(ctx context.Context, domain string, globalOptions types.GlobalCommandOptions, insecure bool, ref string, manifest interface{}) (*ocispec.Platform, error) { + resolver, err := CreateResolver(ctx, domain, globalOptions, insecure) + if err != nil { + return nil, fmt.Errorf("failed to create resolver: %w", err) + } + + if ociManifest, ok := manifest.(manifesttypes.OCIManifestStruct); ok { + if ociManifest.Config.Digest != "" { + platform, err := getPlatformFromConfig(ctx, resolver, ref, ociManifest.Config) + if err == nil && platform != nil { + return platform, nil + } + } + } + + if dockerManifest, ok := manifest.(manifesttypes.DockerManifestStruct); ok { + if dockerManifest.Config.Digest != "" { + platform, err := getPlatformFromConfig(ctx, resolver, ref, dockerManifest.Config) + if err == nil && platform != nil { + return platform, nil + } + } + } + + return &ocispec.Platform{}, nil +} diff --git a/pkg/namestore/namestore.go b/pkg/namestore/namestore.go index 6ded12d6c95..6b65269ef36 100644 --- a/pkg/namestore/namestore.go +++ b/pkg/namestore/namestore.go @@ -40,7 +40,7 @@ func New(stateDir, namespace string) (NameStore, error) { return nil, errors.Join(ErrNameStore, store.ErrInvalidArgument) } - st, err := store.New(filepath.Join(stateDir, namespace), 0, 0) + st, err := store.New(filepath.Join(stateDir, "names", namespace), 0, 0) if err != nil { return nil, errors.Join(ErrNameStore, err) } diff --git a/pkg/namestore/namestore_test.go b/pkg/namestore/namestore_test.go new file mode 100644 index 00000000000..b426d8d63e3 --- /dev/null +++ b/pkg/namestore/namestore_test.go @@ -0,0 +1,70 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package namestore + +import ( + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/store" +) + +func TestNamestoreNew(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + namespace string + wantErr bool + errChecks []error + }{ + { + name: "empty namespace", + namespace: "", + wantErr: true, + errChecks: []error{ErrNameStore, store.ErrInvalidArgument}, + }, + { + name: "valid namespace", + namespace: "testnamespace", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ns, err := New(tempDir, tt.namespace) + if tt.wantErr { + assert.Assert(t, err != nil, "New should return an error for %s", tt.name) + for _, errCheck := range tt.errChecks { + assert.ErrorIs(t, err, errCheck, "Error should contain %v for %s", errCheck, tt.name) + } + } else { + assert.NilError(t, err, "New should succeed for %s", tt.name) + assert.Assert(t, ns != nil, "New should return a non-nil NameStore for %s", tt.name) + + // Check that the directory is created in the correct path + expectedDir := filepath.Join(tempDir, "names", tt.namespace) + _, err = os.Stat(expectedDir) + assert.NilError(t, err, "Directory should be created at the correct path for %s", tt.name) + } + }) + } +} diff --git a/pkg/netutil/cni_plugin_unix.go b/pkg/netutil/cni_plugin_unix.go index 8d863d3be93..2851c7b5a3a 100644 --- a/pkg/netutil/cni_plugin_unix.go +++ b/pkg/netutil/cni_plugin_unix.go @@ -95,13 +95,18 @@ type firewallConfig struct { // IngressPolicy is supported since firewall plugin v1.1.0. // "same-bridge" mode replaces the deprecated "isolation" plugin. + // "isolated" mode has been added since firewall plugin v1.7.1 IngressPolicy string `json:"ingressPolicy,omitempty"` } -func newFirewallPlugin() *firewallConfig { +func newFirewallPlugin(ingressPolicy string) *firewallConfig { + if ingressPolicy != "same-bridge" && ingressPolicy != "isolated" { + ingressPolicy = "same-bridge" // Default to "same-bridge" if invalid value provided + } + c := &firewallConfig{ PluginType: "firewall", - IngressPolicy: "same-bridge", + IngressPolicy: ingressPolicy, } if rootlessutil.IsRootless() { // https://github.com/containerd/nerdctl/issues/2818 diff --git a/pkg/netutil/netutil.go b/pkg/netutil/netutil.go index e97a9125c58..c3312bb5191 100644 --- a/pkg/netutil/netutil.go +++ b/pkg/netutil/netutil.go @@ -37,8 +37,8 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" - "github.com/containerd/nerdctl/v2/pkg/lockutil" "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" subnetutil "github.com/containerd/nerdctl/v2/pkg/netutil/subnet" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -53,14 +53,7 @@ type CNIEnv struct { type CNIEnvOpt func(e *CNIEnv) error func (e *CNIEnv) ListNetworksMatch(reqs []string, allowPseudoNetwork bool) (list map[string][]*NetworkConfig, errs []error) { - var err error - - var networkConfigs []*NetworkConfig - // NOTE: we cannot lock NetconfPath directly, as Cilium (maybe others) are also locking it. - err = lockutil.WithDirLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), func() error { - networkConfigs, err = e.networkConfigList() - return err - }) + networkConfigs, err := fsRead(e) if err != nil { return nil, []error{err} } @@ -188,7 +181,8 @@ func WithDefaultNetwork(bridgeIP string) CNIEnvOpt { func WithNamespace(namespace string) CNIEnvOpt { return func(e *CNIEnv) error { - if err := os.MkdirAll(filepath.Join(e.NetconfPath, namespace), 0755); err != nil { + err := fsEnsureRoot(e, namespace) + if err != nil { return err } e.Namespace = namespace @@ -201,7 +195,8 @@ func NewCNIEnv(cniPath, cniConfPath string, opts ...CNIEnvOpt) (*CNIEnv, error) Path: cniPath, NetconfPath: cniConfPath, } - if err := os.MkdirAll(e.NetconfPath, 0755); err != nil { + + if err := fsEnsureRoot(&e, ""); err != nil { return nil, err } @@ -215,25 +210,17 @@ func NewCNIEnv(cniPath, cniConfPath string, opts ...CNIEnvOpt) (*CNIEnv, error) } func (e *CNIEnv) NetworkList() ([]*NetworkConfig, error) { - var netConfigList []*NetworkConfig - var err error - fn := func() error { - netConfigList, err = e.networkConfigList() - return err - } - err = lockutil.WithDirLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), fn) - - return netConfigList, err + return fsRead(e) } func (e *CNIEnv) NetworkMap() (map[string]*NetworkConfig, error) { //nolint:revive - networks, err := e.networkConfigList() + netConfigList, err := fsRead(e) if err != nil { return nil, err } - m := make(map[string]*NetworkConfig, len(networks)) - for _, n := range networks { + m := make(map[string]*NetworkConfig, len(netConfigList)) + for _, n := range netConfigList { if original, exists := m[n.Name]; exists { log.L.Warnf("duplicate network name %q, %#v will get superseded by %#v", n.Name, original, n) } @@ -243,12 +230,12 @@ func (e *CNIEnv) NetworkMap() (map[string]*NetworkConfig, error) { //nolint:revi } func (e *CNIEnv) NetworkByNameOrID(key string) (*NetworkConfig, error) { - networks, err := e.networkConfigList() + netConfigList, err := fsRead(e) if err != nil { return nil, err } - for _, n := range networks { + for _, n := range netConfigList { if n.Name == key { return n, nil } @@ -261,12 +248,12 @@ func (e *CNIEnv) NetworkByNameOrID(key string) (*NetworkConfig, error) { } func (e *CNIEnv) filterNetworks(filterf func(*NetworkConfig) bool) ([]*NetworkConfig, error) { - networkConfigs, err := e.networkConfigList() + netConfigList, err := fsRead(e) if err != nil { return nil, err } result := []*NetworkConfig{} - for _, networkConfig := range networkConfigs { + for _, networkConfig := range netConfigList { if filterf(networkConfig) { result = append(result, networkConfig) } @@ -274,23 +261,18 @@ func (e *CNIEnv) filterNetworks(filterf func(*NetworkConfig) bool) ([]*NetworkCo return result, nil } -func (e *CNIEnv) getConfigPathForNetworkName(netName string) string { - if netName == DefaultNetworkName || e.Namespace == "" { - return filepath.Join(e.NetconfPath, "nerdctl-"+netName+".conflist") - } - return filepath.Join(e.NetconfPath, e.Namespace, "nerdctl-"+netName+".conflist") -} - func (e *CNIEnv) usedSubnets() ([]*net.IPNet, error) { usedSubnets, err := subnetutil.GetLiveNetworkSubnets() if err != nil { return nil, err } - networkConfigs, err := e.networkConfigList() + + netConfigList, err := fsRead(e) if err != nil { return nil, err } - for _, netConf := range networkConfigs { + + for _, netConf := range netConfigList { usedSubnets = append(usedSubnets, netConf.subnets()...) } return usedSubnets, nil @@ -314,44 +296,39 @@ type cniNetworkConfig struct { func (e *CNIEnv) CreateNetwork(opts types.NetworkCreateOptions) (*NetworkConfig, error) { //nolint:revive var netConf *NetworkConfig - fn := func() error { - netMap, err := e.NetworkMap() - if err != nil { - return err - } + netMap, err := e.NetworkMap() + if err != nil { + return nil, err + } - if _, ok := netMap[opts.Name]; ok { - return errdefs.ErrAlreadyExists - } - ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.IPAMOptions, opts.IPv6) - if err != nil { - return err - } - plugins, err := e.generateCNIPlugins(opts.Driver, opts.Name, ipam, opts.Options, opts.IPv6) - if err != nil { - return err - } - netConf, err = e.generateNetworkConfig(opts.Name, opts.Labels, plugins) - if err != nil { - return err - } - return e.writeNetworkConfig(netConf) + // See note in fsWrite. Just because it does not exist now does not guarantee it will still not exist later. + // This is more a perf optimization at this point than a true check. + if _, ok := netMap[opts.Name]; ok { + return nil, errdefs.ErrAlreadyExists } - err := lockutil.WithDirLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), fn) + ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.IPAMOptions, opts.IPv6, opts.Internal) if err != nil { return nil, err } + plugins, err := e.generateCNIPlugins(opts.Driver, opts.Name, ipam, opts.Options, opts.IPv6, opts.Internal) + if err != nil { + return nil, err + } + netConf, err = e.generateNetworkConfig(opts.Name, opts.Labels, plugins) + if err != nil { + return nil, err + } + err = fsWrite(e, netConf) + + // See note above. If it exists, we got raced out by another process. Consider this to NOT be a hard error. + if err != nil && !errdefs.IsAlreadyExists(err) { + return nil, err + } return netConf, nil } func (e *CNIEnv) RemoveNetwork(net *NetworkConfig) error { - fn := func() error { - if err := os.RemoveAll(net.File); err != nil { - return err - } - return net.clean() - } - return lockutil.WithDirLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), fn) + return fsRemove(e, net) } // GetDefaultNetworkConfig checks whether the default network exists @@ -394,8 +371,8 @@ func (e *CNIEnv) GetDefaultNetworkConfig() (*NetworkConfig, error) { // Warn the user if the default network was not created by nerdctl. match := nameMatches[0] - _, statErr := os.Stat(e.getConfigPathForNetworkName(DefaultNetworkName)) - if match.NerdctlID == nil || statErr != nil { + exists, statErr := fsExists(e, DefaultNetworkName) + if match.NerdctlID == nil || statErr != nil || !exists { log.L.Warnf("default network named %q does not have an internal nerdctl ID or nerdctl-managed config file, it was most likely NOT created by nerdctl", DefaultNetworkName) } @@ -419,9 +396,12 @@ func (e *CNIEnv) ensureDefaultNetworkConfig(bridgeIP string) error { } func (e *CNIEnv) createDefaultNetworkConfig(bridgeIP string) error { - filename := e.getConfigPathForNetworkName(DefaultNetworkName) - if _, err := os.Stat(filename); err == nil { - return fmt.Errorf("already found existing network config at %q, cannot create new network named %q", filename, DefaultNetworkName) + exist, err := fsExists(e, DefaultNetworkName) + if err != nil && !os.IsNotExist(err) { + return err + } + if exist { + return fmt.Errorf("already found existing network config, cannot create new network named %q", DefaultNetworkName) } bridgeCIDR := DefaultCIDR @@ -443,7 +423,7 @@ func (e *CNIEnv) createDefaultNetworkConfig(bridgeIP string) error { Labels: []string{fmt.Sprintf("%s=true", labels.NerdctlDefaultNetwork)}, } - _, err := e.CreateNetwork(opts) + _, err = e.CreateNetwork(opts) if err != nil && !errdefs.IsAlreadyExists(err) { return err } @@ -490,31 +470,6 @@ func (e *CNIEnv) generateNetworkConfig(name string, labels []string, plugins []C }, nil } -// writeNetworkConfig writes NetworkConfig file to cni config path. -func (e *CNIEnv) writeNetworkConfig(net *NetworkConfig) error { - filename := e.getConfigPathForNetworkName(net.Name) - if _, err := os.Stat(filename); err == nil { - return errdefs.ErrAlreadyExists - } - return os.WriteFile(filename, net.Bytes, 0644) -} - -// networkConfigList loads config from dir if dir exists. -func (e *CNIEnv) networkConfigList() ([]*NetworkConfig, error) { - common, err := libcni.ConfFiles(e.NetconfPath, []string{".conf", ".conflist", ".json"}) - if err != nil { - return nil, err - } - namespaced := []string{} - if e.Namespace != "" { - namespaced, err = libcni.ConfFiles(filepath.Join(e.NetconfPath, e.Namespace), []string{".conf", ".conflist", ".json"}) - if err != nil { - return nil, err - } - } - return cniLoad(append(common, namespaced...)) -} - func wrapCNIError(fileName string, err error) error { return fmt.Errorf("failed marshalling json out of network configuration file %q: %w\n"+ "For details on the schema, see https://pkg.go.dev/github.com/containernetworking/cni/libcni#NetworkConfigList", fileName, err) @@ -527,7 +482,7 @@ func cniLoad(fileNames []string) (configList []*NetworkConfig, err error) { for _, fileName = range fileNames { var bytes []byte - bytes, err = os.ReadFile(fileName) + bytes, err = filesystem.ReadFile(fileName) if err != nil { return nil, fmt.Errorf("error reading %s: %w", fileName, err) } diff --git a/pkg/netutil/netutil_test.go b/pkg/netutil/netutil_test.go index c3dadc74360..4c5459e706d 100644 --- a/pkg/netutil/netutil_test.go +++ b/pkg/netutil/netutil_test.go @@ -30,6 +30,7 @@ import ( "gotest.tools/v3/assert" ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/testutil" ) @@ -329,7 +330,7 @@ func TestNetworkWithDefaultNameAlreadyExists(t *testing.T) { // Filename is irrelevant as long as it's not nerdctl's. testConfFile := filepath.Join(cniConfTestDir, fmt.Sprintf("%s.conf", testutil.Identifier(t))) - err = os.WriteFile(testConfFile, buf.Bytes(), 0600) + err = filesystem.WriteFile(testConfFile, buf.Bytes(), 0600) assert.NilError(t, err) // Check network is detected. diff --git a/pkg/netutil/netutil_unix.go b/pkg/netutil/netutil_unix.go index ffb1d8503a8..046c173d122 100644 --- a/pkg/netutil/netutil_unix.go +++ b/pkg/netutil/netutil_unix.go @@ -90,7 +90,7 @@ func (n *NetworkConfig) clean() error { return nil } -func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool) ([]CNIPlugin, error) { +func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool, internal bool) ([]CNIPlugin, error) { var ( plugins []CNIPlugin err error @@ -99,6 +99,7 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] case "bridge": mtu := 0 iPMasq := true + icc := true for opt, v := range opts { switch opt { case "mtu", "com.docker.network.driver.mtu": @@ -111,6 +112,11 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] if err != nil { return nil, err } + case "icc", "com.docker.network.bridge.enable_icc": + icc, err = strconv.ParseBool(v) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unsupported %q network option %q", driver, opt) } @@ -123,16 +129,39 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] } bridge.MTU = mtu bridge.IPAM = ipam - bridge.IsGW = true - bridge.IPMasq = iPMasq + bridge.IsGW = !internal + if internal { + bridge.IPMasq = false + } else { + bridge.IPMasq = iPMasq + } bridge.HairpinMode = true if ipv6 { bridge.Capabilities["ips"] = true } - plugins = []CNIPlugin{bridge, newPortMapPlugin(), newFirewallPlugin(), newTuningPlugin()} + + // Determine the appropriate firewall ingress policy based on icc setting + ingressPolicy := "same-bridge" // Default policy + firewallPath := filepath.Join(e.Path, "firewall") + if !icc { + // Check if firewall plugin supports the "isolated" policy (v1.7.1+) + ok, err := FirewallPluginGEQVersion(firewallPath, "v1.7.1") + if err != nil { + log.L.WithError(err).Warnf("Failed to detect whether %q is newer than v1.7.1", firewallPath) + } else if ok { + ingressPolicy = "isolated" + } else { + log.L.Warnf("To use 'isolated' ingress policy, CNI plugin \"firewall\" (>= 1.7.1) needs to be installed in CNI_PATH (%q), see https://www.cni.dev/plugins/current/meta/firewall/", e.Path) + } + } + + if internal { + plugins = []CNIPlugin{bridge, newFirewallPlugin(ingressPolicy), newTuningPlugin()} + } else { + plugins = []CNIPlugin{bridge, newPortMapPlugin(), newFirewallPlugin(ingressPolicy), newTuningPlugin()} + } if name != DefaultNetworkName { - firewallPath := filepath.Join(e.Path, "firewall") - ok, err := firewallPluginGEQ110(firewallPath) + ok, err := FirewallPluginGEQVersion(firewallPath, "v1.1.0") if err != nil { log.L.WithError(err).Warnf("Failed to detect whether %q is newer than v1.1.0", firewallPath) } @@ -186,13 +215,15 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool) (map[string]interface{}, error) { +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { var ipamConfig interface{} switch driver { case "default", "host-local": ipamConf := newHostLocalIPAMConfig() - ipamConf.Routes = []IPAMRoute{ - {Dst: "0.0.0.0/0"}, + if !internal { + ipamConf.Routes = []IPAMRoute{ + {Dst: "0.0.0.0/0"}, + } } ranges, findIPv4, err := e.parseIPAMRanges(subnets, gatewayStr, ipRangeStr, ipv6) if err != nil { @@ -281,7 +312,8 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateway, ipRange string, ipv6 return ranges, findIPv4, nil } -func firewallPluginGEQ110(firewallPath string) (bool, error) { +// FirewallPluginGEQVersion checks if the firewall plugin is greater than or equal to the specified version +func FirewallPluginGEQVersion(firewallPath string, versionStr string) (bool, error) { // TODO: guess true by default in 2023 guessed := false @@ -310,8 +342,8 @@ func firewallPluginGEQ110(firewallPath string) (bool, error) { if err != nil { return guessed, fmt.Errorf("failed to guess the version of %q: %w", firewallPath, err) } - ver110 := semver.MustParse("v1.1.0") - return ver.GreaterThan(ver110) || ver.Equal(ver110), nil + targetVer := semver.MustParse(versionStr) + return ver.GreaterThan(targetVer) || ver.Equal(targetVer), nil } // guessFirewallPluginVersion guess the version of the CNI firewall plugin (not the version of the implemented CNI spec). diff --git a/pkg/netutil/netutil_windows.go b/pkg/netutil/netutil_windows.go index 8e0e67a01ed..484b03c9b77 100644 --- a/pkg/netutil/netutil_windows.go +++ b/pkg/netutil/netutil_windows.go @@ -18,6 +18,7 @@ package netutil import ( "encoding/json" + "errors" "fmt" "net" @@ -30,7 +31,7 @@ const ( // When creating non-default network without passing in `--subnet` option, // nerdctl assigns subnet address for the creation starting from `StartingCIDR` - // This prevents subnet address overlapping with `DefaultCIDR` used by the default networkß + // This prevents subnet address overlapping with `DefaultCIDR` used by the default network StartingCIDR = "10.4.1.0/24" ) @@ -58,7 +59,7 @@ func (n *NetworkConfig) clean() error { return nil } -func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool) ([]CNIPlugin, error) { +func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool, internal bool) ([]CNIPlugin, error) { var plugins []CNIPlugin switch driver { case "nat": @@ -71,7 +72,7 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool) (map[string]interface{}, error) { +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { switch driver { case "default": default: @@ -95,3 +96,7 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRan } return ipam, nil } + +func FirewallPluginGEQVersion(firewallPath string, versionStr string) (bool, error) { + return false, errors.New("unsupported in windows") +} diff --git a/pkg/netutil/networkstore/networkstore.go b/pkg/netutil/networkstore/networkstore.go new file mode 100644 index 00000000000..2df313459b1 --- /dev/null +++ b/pkg/netutil/networkstore/networkstore.go @@ -0,0 +1,114 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package networkstore + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/store" +) + +const ( + containersDirBaseName = "containers" + networkConfigName = "network-config.json" +) + +var ErrNetworkStore = errors.New("network-store error") + +func New(dataStore, namespace, containerID string) (ns *NetworkStore, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNetworkStore, err) + } + }() + + if dataStore == "" || namespace == "" || containerID == "" { + return nil, fmt.Errorf("either dataStore or namespace or containerID is empty") + } + + st, err := store.New(filepath.Join(dataStore, containersDirBaseName, namespace, containerID), 0, 0o600) + if err != nil { + return nil, err + } + + return &NetworkStore{ + safeStore: st, + }, nil +} + +type NetworkConfig struct { + PortMappings []cni.PortMapping `json:"portMappings,omitempty"` +} + +type NetworkStore struct { + safeStore store.Store + + NetConf NetworkConfig +} + +func (ns *NetworkStore) Acquire(netConf NetworkConfig) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNetworkStore, err) + } + }() + + netConfJSON, err := json.Marshal(netConf) + if err != nil { + return fmt.Errorf("failed to marshal network config to JSON: %w", err) + } + + return ns.safeStore.WithLock(func() error { + return ns.safeStore.Set(netConfJSON, networkConfigName) + }) +} + +func (ns *NetworkStore) Load() (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNetworkStore, err) + } + }() + + return ns.safeStore.WithLock(func() error { + doesExist, err := ns.safeStore.Exists(networkConfigName) + if err != nil || !doesExist { + return err + } + + data, err := ns.safeStore.Get(networkConfigName) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + err = nil + } + return err + } + + var netConf NetworkConfig + if err := json.Unmarshal(data, &netConf); err != nil { + return fmt.Errorf("failed to parse network config %v: %w", netConf, err) + } + ns.NetConf = netConf + + return err + }) +} diff --git a/pkg/netutil/store.go b/pkg/netutil/store.go new file mode 100644 index 00000000000..7376b3510c5 --- /dev/null +++ b/pkg/netutil/store.go @@ -0,0 +1,100 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package netutil + +import ( + "os" + "path/filepath" + + "github.com/containernetworking/cni/libcni" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" +) + +// NOTE: libcni is not safe to use concurrently - or at least delegates concurrency management to the consumer. +// Furthermore, CNIEnv (prior to this) is assuming the filesystem is ACID and other TOCTOU faults. +// This small set of methods here are meant to isolate CNIEnv entirely from the filesystem. +// This is NOT proper - we should instead use the Store implementation, which is the generic abstraction for ACID +// operations - but for now that will do, waiting for a full rewrite of CNIEnv. + +func fsEnsureRoot(e *CNIEnv, namespace string) error { + path := e.NetconfPath + if namespace != "" { + path = filepath.Join(e.NetconfPath, namespace) + } + return os.MkdirAll(path, 0755) +} + +func fsRemove(e *CNIEnv, net *NetworkConfig) error { + fn := func() error { + if err := os.RemoveAll(net.File); err != nil { + return err + } + return net.clean() + } + return filesystem.WithLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), fn) +} + +func fsExists(e *CNIEnv, name string) (bool, error) { + fi, err := os.Stat(getConfigPathForNetworkName(e, name)) + return !os.IsNotExist(err) && !fi.IsDir(), err +} + +func fsWrite(e *CNIEnv, net *NetworkConfig) error { + filename := getConfigPathForNetworkName(e, net.Name) + // FIXME: note that this is still problematic. + // Concurrent access may independently first figure out that a given network is missing, and while the lock + // here will prevent concurrent writes, one of the routines will fail. + // Consuming code MUST account for that scenario. + return filesystem.WithLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), func() error { + if _, err := os.Stat(filename); err == nil { + return errdefs.ErrAlreadyExists + } + return filesystem.WriteFile(filename, net.Bytes, 0644) + }) +} + +func fsRead(e *CNIEnv) ([]*NetworkConfig, error) { + var nc []*NetworkConfig + var err error + err = filesystem.WithReadOnlyLock(filepath.Join(e.NetconfPath, ".nerdctl.lock"), func() error { + namespaced := []string{} + var common []string + common, err = libcni.ConfFiles(e.NetconfPath, []string{".conf", ".conflist", ".json"}) + if err != nil { + return err + } + if e.Namespace != "" { + namespaced, err = libcni.ConfFiles(filepath.Join(e.NetconfPath, e.Namespace), []string{".conf", ".conflist", ".json"}) + if err != nil { + return err + } + } + nc, err = cniLoad(append(common, namespaced...)) + return err + }) + return nc, err +} + +func getConfigPathForNetworkName(e *CNIEnv, netName string) string { + if netName == DefaultNetworkName || e.Namespace == "" { + return filepath.Join(e.NetconfPath, "nerdctl-"+netName+".conflist") + } + return filepath.Join(e.NetconfPath, e.Namespace, "nerdctl-"+netName+".conflist") +} diff --git a/pkg/ocihook/ocihook.go b/pkg/ocihook/ocihook.go index 2807b23e9d8..e860f1b1a68 100644 --- a/pkg/ocihook/ocihook.go +++ b/pkg/ocihook/ocihook.go @@ -24,7 +24,9 @@ import ( "io" "net" "os" + "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -38,12 +40,13 @@ import ( "github.com/containerd/nerdctl/v2/pkg/bypass4netnsutil" "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/labels" - "github.com/containerd/nerdctl/v2/pkg/lockutil" "github.com/containerd/nerdctl/v2/pkg/namestore" "github.com/containerd/nerdctl/v2/pkg/netutil" "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" "github.com/containerd/nerdctl/v2/pkg/ocihook/state" + "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/store" ) @@ -103,15 +106,17 @@ func Run(stdin io.Reader, stderr io.Writer, event, dataStore, cniPath, cniNetcon // This below is a stopgap solution that just enforces a global lock // Note this here is probably not enough, as concurrent CNI operations may happen outside of the scope of ocihooks // through explicit calls to Remove, etc. + // Finally note that this is not the same (albeit similar) as libcni filesystem manipulation locking, + // hence the independent lock err = os.MkdirAll(cniNetconfPath, 0o700) if err != nil { return err } - lock, err := lockutil.Lock(filepath.Join(cniNetconfPath, ".nerdctl.lock")) + lock, err := filesystem.Lock(filepath.Join(cniNetconfPath, ".cni-concurrency.lock")) if err != nil { return err } - defer lockutil.Unlock(lock) + defer filesystem.Unlock(lock) opts, err := newHandlerOpts(&state, dataStore, cniPath, cniNetconfPath, bridgeIP) if err != nil { @@ -205,11 +210,11 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, brid } } - if portsJSON := o.state.Annotations[labels.Ports]; portsJSON != "" { - if err := json.Unmarshal([]byte(portsJSON), &o.ports); err != nil { - return nil, err - } + ports, err := portutil.LoadPortMappings(o.dataStore, namespace, o.state.ID, o.state.Annotations) + if err != nil { + return nil, err } + o.ports = ports if ipAddress, ok := o.state.Annotations[labels.IPAddress]; ok { o.containerIP = ipAddress @@ -416,11 +421,94 @@ func getIP6AddressOpts(opts *handlerOpts) ([]cni.NamespaceOpts, error) { return nil, nil } -func applyNetworkSettings(opts *handlerOpts) error { +func reserveSocket(protocol, hostAddr string) (*os.File, error) { + type filer interface { + File() (*os.File, error) + } + var f filer + switch { + case strings.HasPrefix(protocol, "tcp"): + l, err := net.Listen(protocol, hostAddr) + if err != nil { + return nil, err + } + defer l.Close() + var ok bool + f, ok = l.(filer) + if !ok { + return nil, fmt.Errorf("cannot get file descriptor from the listener of type %T", l) + } + case strings.HasPrefix(protocol, "udp"): + l, err := net.ListenPacket(protocol, hostAddr) + if err != nil { + return nil, err + } + defer l.Close() + var ok bool + f, ok = l.(filer) + if !ok { + return nil, fmt.Errorf("cannot get file descriptor from the listener of type %T", l) + } + default: + return nil, fmt.Errorf("unsupported protocol %q", protocol) + } + return f.File() +} + +// portReserverPidFilePath returns /run/nerdctl///port-reserver.pid +func portReserverPidFilePath(opts *handlerOpts) string { + return filepath.Join("/run/nerdctl/", opts.state.Annotations[labels.Namespace], opts.state.ID, "port-reserver.pid") +} + +func applyNetworkSettings(opts *handlerOpts) (err error) { portMapOpts, err := getPortMapOpts(opts) if err != nil { return err } + if !rootlessutil.IsRootlessChild() && len(opts.ports) > 0 { + // When running in rootful mode, reserve the ports on the host + // so that the ports appears on /proc/net/tcp. + // + // This also prevents other processes from binding to the same ports. + // + // Note that in rootless mode this is not necessary because + // RootlessKit's port driver already reserves the ports. + // + // See https://github.com/lima-vm/lima/issues/4085 + // + // Similar patterns are used in Docker and Podman. + // - https://github.com/moby/moby/pull/48132 + // - https://github.com/containers/podman/pull/23446 + reserverCmd := exec.Command("sleep", "infinity") + for _, p := range opts.ports { + protocol := p.Protocol + if !strings.HasSuffix(protocol, "4") && !strings.HasSuffix(protocol, "6") { + // e.g. "tcp" -> "tcp4" + protocol += "4" + } + hostAddr := net.JoinHostPort(p.HostIP, strconv.Itoa(int(p.HostPort))) + f, err := reserveSocket(protocol, hostAddr) + if err != nil { + log.L.WithError(err).Warnf("cannot reserve the port %s/%s", hostAddr, protocol) + continue + } + reserverCmd.ExtraFiles = append(reserverCmd.ExtraFiles, f) + } + if err := reserverCmd.Start(); err != nil { + return fmt.Errorf("cannot start the port reserver process: %w", err) + } + reserverCmdPid := reserverCmd.Process.Pid + log.L.Debugf("started the port reserver process (pid=%d)", reserverCmdPid) + defer func() { + if err != nil { + log.L.Debugf("killing the port reserver process (pid=%d)", reserverCmdPid) + _ = reserverCmd.Process.Kill() + } + }() + if err := writePidFile(portReserverPidFilePath(opts), reserverCmdPid); err != nil { + return fmt.Errorf("cannot write the pid file of the port reserver process: %w", err) + } + } nsPath, err := getNetNSPath(opts.state) if err != nil { return err @@ -473,10 +561,19 @@ func applyNetworkSettings(opts *handlerOpts) error { // See https://github.com/containerd/nerdctl/issues/3355 _ = opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...) + // Defer CNI configuration removal to ensure idempotency of oci-hook. + defer func() { + if err != nil { + log.L.Warn("Container failed starting. Removing allocated network configuration.") + _ = opts.cni.Remove(ctx, opts.fullID, nsPath, namespaceOpts...) + } + }() + cniRes, err := opts.cni.Setup(ctx, opts.fullID, nsPath, namespaceOpts...) if err != nil { return fmt.Errorf("failed to call cni.Setup: %w", err) } + cniResRaw := cniRes.Raw() for i, cniName := range opts.cniNames { hsMeta.Networks[cniName] = cniResRaw[i] @@ -620,6 +717,15 @@ func onPostStop(opts *handlerOpts) error { log.L.WithError(err).Errorf("failed to call cni.Remove") return err } + + // opts.cni.Remove has trouble removing network configurations when netns is empty. + // Therefore, we force the deletion of iptables rules here to prevent netns exhaustion. + // This is a workaround until https://github.com/containernetworking/plugins/pull/1078 is merged. + if err := cleanupIptablesRules(opts.fullID); err != nil { + log.L.WithError(err).Warnf("failed to clean up iptables rules for container %s", opts.fullID) + // Don't return error here, continue with the rest of the cleanup + } + hs, err := hostsstore.New(opts.dataStore, ns) if err != nil { return err @@ -637,6 +743,48 @@ func onPostStop(opts *handlerOpts) error { if err := namst.Release(name, opts.state.ID); err != nil && !errors.Is(err, store.ErrNotFound) { return fmt.Errorf("failed to release container name %s: %w", name, err) } + // Kill port-reserver process if any + portReserverPidFile := portReserverPidFilePath(opts) + if err = killProcessByPidFile(portReserverPidFile); err != nil { + log.L.WithError(err).Errorf("failed to kill the port-reserver process") + } + return nil +} + +// cleanupIptablesRules cleans up iptables rules related to the container +func cleanupIptablesRules(containerID string) error { + // Check if iptables command exists + if _, err := exec.LookPath("iptables"); err != nil { + return fmt.Errorf("iptables command not found: %w", err) + } + + // Tables to check for rules + tables := []string{"nat", "filter", "mangle"} + + for _, table := range tables { + // Get all iptables rules for this table + cmd := exec.Command("iptables", "-t", table, "-S") + output, err := cmd.CombinedOutput() + if err != nil { + log.L.WithError(err).Warnf("failed to list iptables rules for table %s", table) + continue + } + + // Find and delete rules related to the container + rules := strings.Split(string(output), "\n") + for _, rule := range rules { + if strings.Contains(rule, containerID) { + // Execute delete command + deleteCmd := exec.Command("sh", "-c", "--", fmt.Sprintf(`iptables -t %s -D %s`, table, rule[3:])) + if deleteOutput, err := deleteCmd.CombinedOutput(); err != nil { + log.L.WithError(err).Warnf("failed to delete iptables rule: %s, output: %s", rule, string(deleteOutput)) + } else { + log.L.Debugf("deleted iptables rule: %s", rule) + } + } + } + } + return nil } @@ -647,7 +795,11 @@ func writePidFile(path string, pid int) error { if err != nil { return err } - tempPath := filepath.Join(filepath.Dir(path), fmt.Sprintf(".%s", filepath.Base(path))) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + tempPath := filepath.Join(dir, fmt.Sprintf(".%s", filepath.Base(path))) f, err := os.OpenFile(tempPath, os.O_RDWR|os.O_CREATE|os.O_EXCL|os.O_SYNC, 0666) if err != nil { return err @@ -659,3 +811,25 @@ func writePidFile(path string, pid int) error { } return os.Rename(tempPath, path) } + +func killProcessByPidFile(pidFile string) error { + pidData, err := os.ReadFile(pidFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + } + return err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return fmt.Errorf("failed to parse pid %q from %q: %w", string(pidData), pidFile, err) + } + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process %d: %w", pid, err) + } + if err := proc.Kill(); err != nil { + return fmt.Errorf("failed to kill process %d: %w", pid, err) + } + return nil +} diff --git a/pkg/portutil/port_allocate_linux.go b/pkg/portutil/port_allocate_linux.go index bd396a52555..5e2a5956b90 100644 --- a/pkg/portutil/port_allocate_linux.go +++ b/pkg/portutil/port_allocate_linux.go @@ -25,11 +25,14 @@ import ( const ( // This port range is compatible with Docker, FYI https://github.com/moby/moby/blob/eb9e42a09ee123af1d95bf7d46dd738258fa2109/libnetwork/portallocator/portallocator_unix.go#L7-L12 - allocateEnd = 60999 + allocateEnd = uint64(60999) + + tcpTimeWait = 6 //TIME_WAIT state is represented by the value 6 in /proc/net/tcp + tcpCloseWait = 8 //CLOSE_WAIT state is represented by the value 8 in /proc/net/tcp ) var ( - allocateStart = 49153 + allocateStart = uint64(49153) ) func filter(ss []procnet.NetworkDetail, filterFunc func(detail procnet.NetworkDetail) bool) (ret []procnet.NetworkDetail) { @@ -42,24 +45,56 @@ func filter(ss []procnet.NetworkDetail, filterFunc func(detail procnet.NetworkDe } func portAllocate(protocol string, ip string, count uint64) (uint64, uint64, error) { - netprocData, err := procnet.ReadStatsFileData(protocol) + usedPorts, err := getUsedPorts(ip, protocol) if err != nil { return 0, 0, err } - netprocItems := procnet.Parse(netprocData) + + start := allocateStart + if count > allocateEnd-allocateStart+1 { + return 0, 0, fmt.Errorf("can not allocate %d ports", count) + } + for start < allocateEnd { + needReturn := true + for i := start; i < start+count; i++ { + if _, ok := usedPorts[i]; ok { + needReturn = false + break + } + } + if needReturn { + allocateStart = start + count + return start, start + count - 1, nil + } + start += count + } + return 0, 0, fmt.Errorf("there is not enough %d free ports", count) +} + +func getUsedPorts(ip string, protocol string) (map[uint64]bool, error) { + netprocItems := []procnet.NetworkDetail{} + + if protocol == "tcp" || protocol == "udp" { + netprocData, err := procnet.ReadStatsFileData(protocol) + if err != nil { + return nil, err + } + netprocItems = append(netprocItems, procnet.Parse(netprocData)...) + } + // In some circumstances, when we bind address like "0.0.0.0:80", we will get the formation of ":::80" in /proc/net/tcp6. // So we need some trick to process this situation. if protocol == "tcp" { tempTCPV6Data, err := procnet.ReadStatsFileData("tcp6") if err != nil { - return 0, 0, err + return nil, err } netprocItems = append(netprocItems, procnet.Parse(tempTCPV6Data)...) } if protocol == "udp" { tempUDPV6Data, err := procnet.ReadStatsFileData("udp6") if err != nil { - return 0, 0, err + return nil, err } netprocItems = append(netprocItems, procnet.Parse(tempUDPV6Data)...) } @@ -73,12 +108,20 @@ func portAllocate(protocol string, ip string, count uint64) (uint64, uint64, err usedPort := make(map[uint64]bool) for _, value := range netprocItems { + // Skip ports in TIME_WAIT or CLOSE_WAIT state + if protocol == "tcp" && (value.State == tcpTimeWait || value.State == tcpCloseWait) { + // In rootless mode, Rootlesskit creates extra socket connections to proxy traffic from the host network namespace + // to the container namespace. Proxy TCP connections can remain in TIME_WAIT state for 10-20 seconds even when the + // container is stopped/removed, which is standard TCP behavior. These ports are actually available for allocation + // despite appearing in /proc/net/tcp. + continue + } usedPort[value.LocalPort] = true } ipTableItems, err := iptable.ReadIPTables("nat") if err != nil { - return 0, 0, err + return nil, err } destinationPorts := iptable.ParseIPTableRules(ipTableItems) @@ -86,23 +129,5 @@ func portAllocate(protocol string, ip string, count uint64) (uint64, uint64, err usedPort[port] = true } - start := uint64(allocateStart) - if count > uint64(allocateEnd-allocateStart+1) { - return 0, 0, fmt.Errorf("can not allocate %d ports", count) - } - for start < allocateEnd { - needReturn := true - for i := start; i < start+count; i++ { - if _, ok := usedPort[i]; ok { - needReturn = false - break - } - } - if needReturn { - allocateStart = int(start + count) - return start, start + count - 1, nil - } - start += count - } - return 0, 0, fmt.Errorf("there is not enough %d free ports", count) + return usedPort, nil } diff --git a/pkg/portutil/port_allocate_other.go b/pkg/portutil/port_allocate_other.go index 9749574c97c..957c9538f38 100644 --- a/pkg/portutil/port_allocate_other.go +++ b/pkg/portutil/port_allocate_other.go @@ -23,3 +23,7 @@ import "fmt" func portAllocate(protocol string, ip string, count uint64) (uint64, uint64, error) { return 0, 0, fmt.Errorf("auto port allocate are not support Non-Linux platform yet") } + +func getUsedPorts(ip string, protocol string) (map[uint64]bool, error) { + return nil, nil +} diff --git a/pkg/portutil/portutil.go b/pkg/portutil/portutil.go index 28a1836bb2f..73853767f16 100644 --- a/pkg/portutil/portutil.go +++ b/pkg/portutil/portutil.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/netutil/networkstore" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) @@ -101,6 +102,16 @@ func ParseFlagP(s string) ([]cni.PortMapping, error) { if err != nil { return nil, fmt.Errorf("invalid hostPort: %s", hostPort) } + var usedPorts map[uint64]bool + usedPorts, err = getUsedPorts(ip, proto) + if err != nil { + return nil, err + } + for i := startHostPort; i <= endHostPort; i++ { + if usedPorts[i] { + return nil, fmt.Errorf("bind for %s:%d failed: port is already allocated", ip, i) + } + } } if hostPort != "" && (endPort-startPort) != (endHostPort-startHostPort) { if endPort != startPort { @@ -129,16 +140,35 @@ func ParseFlagP(s string) ([]cni.PortMapping, error) { return mr, nil } -// ParsePortsLabel parses JSON-marshalled string from label map -// (under `labels.Ports` key) and returns []cni.PortMapping. -func ParsePortsLabel(labelMap map[string]string) ([]cni.PortMapping, error) { - portsJSON := labelMap[labels.Ports] - if portsJSON == "" { - return []cni.PortMapping{}, nil +func StoreNetworkConfig(dataStore, namespace, id string, netConf networkstore.NetworkConfig) error { + ns, err := networkstore.New(dataStore, namespace, id) + if err != nil { + return err } + return ns.Acquire(netConf) +} + +func LoadPortMappings(dataStore, namespace, id string, containerLabels map[string]string) ([]cni.PortMapping, error) { var ports []cni.PortMapping + + ns, err := networkstore.New(dataStore, namespace, id) + if err != nil { + return ports, err + } + if err = ns.Load(); err != nil { + return ports, err + } + if len(ns.NetConf.PortMappings) != 0 { + return ns.NetConf.PortMappings, nil + } + + portsJSON := containerLabels[labels.Ports] + if portsJSON == "" { + return ports, nil + } if err := json.Unmarshal([]byte(portsJSON), &ports); err != nil { - return nil, fmt.Errorf("failed to parse label %q=%q: %s", labels.Ports, portsJSON, err.Error()) + return ports, fmt.Errorf("failed to parse label %q=%q: %s", labels.Ports, portsJSON, err.Error()) } + log.L.Warnf("container %s (%s) is using legacy port mapping configuration. To ensure compatibility with the new port mapping logic, please recreate this container. For more details, see: https://github.com/containerd/nerdctl/pull/4290", containerLabels[labels.Name], id[:12]) return ports, nil } diff --git a/pkg/portutil/portutil_test.go b/pkg/portutil/portutil_test.go index d14c79786c3..02f390bb9ab 100644 --- a/pkg/portutil/portutil_test.go +++ b/pkg/portutil/portutil_test.go @@ -22,14 +22,15 @@ import ( "sort" "testing" + "gotest.tools/v3/assert" + "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) -func TestTestParseFlagPWithPlatformSpec(t *testing.T) { - if runtime.GOOS != "Linux" || rootlessutil.IsRootless() { +func TestParseFlagPWithPlatformSpec(t *testing.T) { + if runtime.GOOS != "linux" || rootlessutil.IsRootless() { t.Skip("no non-Linux platform or rootless mode in Linux are not supported yet") } type args struct { @@ -48,7 +49,6 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { }, want: []cni.PortMapping{ { - HostPort: 3000, ContainerPort: 3000, Protocol: "tcp", HostIP: "0.0.0.0", @@ -63,13 +63,11 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { }, want: []cni.PortMapping{ { - HostPort: 3000, ContainerPort: 3000, Protocol: "tcp", HostIP: "0.0.0.0", }, { - HostPort: 3001, ContainerPort: 3001, Protocol: "tcp", HostIP: "0.0.0.0", @@ -92,13 +90,11 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { }, want: []cni.PortMapping{ { - HostPort: 3000, ContainerPort: 3000, Protocol: "tcp", HostIP: "0.0.0.0", }, { - HostPort: 3001, ContainerPort: 3001, Protocol: "tcp", HostIP: "0.0.0.0", @@ -113,15 +109,13 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { }, want: []cni.PortMapping{ { - HostPort: 3000, ContainerPort: 3000, - Protocol: "tcp", + Protocol: "udp", HostIP: "0.0.0.0", }, { - HostPort: 3001, ContainerPort: 3001, - Protocol: "tcp", + Protocol: "udp", HostIP: "0.0.0.0", }, }, @@ -131,113 +125,26 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseFlagP(tt.args.s) - t.Log(err) - if (err != nil) != tt.wantErr { - t.Errorf("ParseFlagP() error = %v, wantErr %v", err, tt.wantErr) - return + if err != nil { + t.Log(err) + assert.Equal(t, true, tt.wantErr) } if !reflect.DeepEqual(got, tt.want) { - if len(got) == len(tt.want) { - if len(got) > 1 { - var hostPorts []int32 - var containerPorts []int32 - for _, value := range got { - hostPorts = append(hostPorts, value.HostPort) - containerPorts = append(containerPorts, value.ContainerPort) - } - sort.Slice(hostPorts, func(i, j int) bool { - return i < j - }) - sort.Slice(containerPorts, func(i, j int) bool { - return i < j - }) - if (hostPorts[len(hostPorts)-1] - hostPorts[0]) != (containerPorts[len(hostPorts)-1] - containerPorts[0]) { - t.Errorf("ParseFlagP() = %v, want %v", got, tt.want) - } + assert.Equal(t, len(got), len(tt.want)) + if len(got) > 0 { + sort.Slice(got, func(i, j int) bool { + return got[i].HostPort < got[j].HostPort + }) + assert.Equal( + t, + got[len(got)-1].HostPort-got[0].HostPort, + got[len(got)-1].ContainerPort-got[0].ContainerPort, + ) + for i := range len(got) { + assert.Equal(t, got[i].ContainerPort, tt.want[i].ContainerPort) + assert.Equal(t, got[i].Protocol, tt.want[i].Protocol) + assert.Equal(t, got[i].HostIP, tt.want[i].HostIP) } - } else { - t.Errorf("ParseFlagP() = %v, want %v", got, tt.want) - } - } - }) - } - -} - -func TestParsePortsLabel(t *testing.T) { - tests := []struct { - name string - labelMap map[string]string - want []cni.PortMapping - wantErr bool - }{ - { - name: "normal", - labelMap: map[string]string{ - labels.Ports: "[{\"HostPort\":12345,\"ContainerPort\":10000,\"Protocol\":\"tcp\",\"HostIP\":\"0.0.0.0\"}]", - }, - want: []cni.PortMapping{ - { - HostPort: 3000, - ContainerPort: 8080, - Protocol: "tcp", - HostIP: "127.0.0.1", - }, - }, - wantErr: false, - }, - { - name: "empty ports (value empty)", - labelMap: map[string]string{ - labels.Ports: "", - }, - want: []cni.PortMapping{}, - wantErr: false, - }, - { - name: "empty ports (key not exists)", - labelMap: map[string]string{}, - want: []cni.PortMapping{}, - wantErr: false, - }, - { - name: "parse error (wrong format)", - labelMap: map[string]string{ - labels.Ports: "{\"HostPort\":12345,\"ContainerPort\":10000,\"Protocol\":\"tcp\",\"HostIP\":\"0.0.0.0\"}", - }, - want: nil, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParsePortsLabel(tt.labelMap) - t.Log(err) - if (err != nil) != tt.wantErr { - t.Errorf("ParsePortsLabel() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - if len(got) == len(tt.want) { - if len(got) > 1 { - var hostPorts []int32 - var containerPorts []int32 - for _, value := range got { - hostPorts = append(hostPorts, value.HostPort) - containerPorts = append(containerPorts, value.ContainerPort) - } - sort.Slice(hostPorts, func(i, j int) bool { - return i < j - }) - sort.Slice(containerPorts, func(i, j int) bool { - return i < j - }) - if (hostPorts[len(hostPorts)-1] - hostPorts[0]) != (containerPorts[len(hostPorts)-1] - containerPorts[0]) { - t.Errorf("ParsePortsLabel() = %v, want %v", got, tt.want) - } - } - } else { - t.Errorf("ParsePortsLabel() = %v, want %v", got, tt.want) } } }) @@ -249,10 +156,10 @@ func TestParseFlagP(t *testing.T) { s string } tests := []struct { - name string - args args - want []cni.PortMapping - wantErr bool + name string + args args + want []cni.PortMapping + wantErrMsg string }{ { name: "normal", @@ -267,7 +174,7 @@ func TestParseFlagP(t *testing.T) { HostIP: "127.0.0.1", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "with port range", @@ -288,15 +195,15 @@ func TestParseFlagP(t *testing.T) { HostIP: "127.0.0.1", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "with wrong port range", args: args{ s: "127.0.0.1:3000-3001:8080-8082/tcp", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: "invalid ranges specified for container and host Ports: 8080-8082 and 3000-3001", }, { name: "without host ip", @@ -311,7 +218,7 @@ func TestParseFlagP(t *testing.T) { HostIP: "0.0.0.0", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "without protocol", @@ -326,7 +233,7 @@ func TestParseFlagP(t *testing.T) { HostIP: "0.0.0.0", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "with protocol udp", @@ -341,10 +248,10 @@ func TestParseFlagP(t *testing.T) { HostIP: "0.0.0.0", }, }, - wantErr: false, + wantErrMsg: "", }, { - name: "with protocol udp", + name: "with protocol sctp", args: args{ s: "3000:8080/sctp", }, @@ -356,7 +263,7 @@ func TestParseFlagP(t *testing.T) { HostIP: "0.0.0.0", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "with ipv6 host ip", @@ -371,86 +278,82 @@ func TestParseFlagP(t *testing.T) { HostIP: "::0", }, }, - wantErr: false, + wantErrMsg: "", }, { name: "with invalid protocol", args: args{ s: "3000:8080/invalid", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: `invalid protocol "invalid"`, }, { name: "multiple colon", args: args{ s: "127.0.0.1:3000:0.0.0.0:8080", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: "invalid hostPort: 127.0.0.1:3000:0.0.0.0", }, { name: "multiple slash", args: args{ s: "127.0.0.1:3000:8080/tcp/", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: `failed to parse "127.0.0.1:3000:8080/tcp/", unexpected slashes`, }, { name: "invalid ip", args: args{ s: "127.0.0.256:3000:8080/tcp", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: "invalid ip address: 127.0.0.256", }, { name: "large port", args: args{ s: "3000:65536", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: "invalid containerPort: 65536", }, { name: "blank", args: args{ s: "", }, - want: nil, - wantErr: true, + want: nil, + wantErrMsg: "no port specified: ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseFlagP(tt.args.s) - t.Log(err) - if (err != nil) != tt.wantErr { - t.Errorf("ParseFlagP() error = %v, wantErr %v", err, tt.wantErr) - return + if tt.wantErrMsg == "" { + assert.NilError(t, err) + } else { + assert.Error(t, err, tt.wantErrMsg) } if !reflect.DeepEqual(got, tt.want) { - if len(got) == len(tt.want) { - if len(got) > 1 { - var hostPorts []int32 - var containerPorts []int32 - for _, value := range got { - hostPorts = append(hostPorts, value.HostPort) - containerPorts = append(containerPorts, value.ContainerPort) - } - sort.Slice(hostPorts, func(i, j int) bool { - return i < j - }) - sort.Slice(containerPorts, func(i, j int) bool { - return i < j - }) - if (hostPorts[len(hostPorts)-1] - hostPorts[0]) != (containerPorts[len(hostPorts)-1] - containerPorts[0]) { - t.Errorf("ParseFlagP() = %v, want %v", got, tt.want) - } + assert.Equal(t, len(got), len(tt.want)) + if len(got) > 0 { + sort.Slice(got, func(i, j int) bool { + return got[i].HostPort < got[j].HostPort + }) + assert.Equal( + t, + got[len(got)-1].HostPort-got[0].HostPort, + got[len(got)-1].ContainerPort-got[0].ContainerPort, + ) + for i := range len(got) { + assert.Equal(t, got[i].HostPort, tt.want[i].HostPort) + assert.Equal(t, got[i].ContainerPort, tt.want[i].ContainerPort) + assert.Equal(t, got[i].Protocol, tt.want[i].Protocol) + assert.Equal(t, got[i].HostIP, tt.want[i].HostIP) } - } else { - t.Errorf("ParseFlagP() = %v, want %v", got, tt.want) } } }) diff --git a/pkg/portutil/procnet/procnet.go b/pkg/portutil/procnet/procnet.go index d5a382bff85..c68b5bed2b9 100644 --- a/pkg/portutil/procnet/procnet.go +++ b/pkg/portutil/procnet/procnet.go @@ -27,6 +27,7 @@ import ( type NetworkDetail struct { LocalIP net.IP LocalPort uint64 + State int } func Parse(data []string) (results []NetworkDetail) { @@ -37,9 +38,19 @@ func Parse(data []string) (results []NetworkDetail) { if err != nil { continue } + + state := 0 + if len(lineData) > 2 { + stateHex, err := strconv.ParseInt(lineData[3], 16, 32) + if err == nil { + state = int(stateHex) + } + } + results = append(results, NetworkDetail{ LocalIP: ip, LocalPort: uint64(port), + State: state, }) } return results diff --git a/pkg/referenceutil/cid_ipfs.go b/pkg/referenceutil/cid_ipfs.go new file mode 100644 index 00000000000..24b6974d7bf --- /dev/null +++ b/pkg/referenceutil/cid_ipfs.go @@ -0,0 +1,29 @@ +//go:build !no_ipfs + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package referenceutil + +import "github.com/ipfs/go-cid" + +func decodeCid(v string) (string, error) { + c, err := cid.Decode(v) + if err != nil { + return "", err + } + return c.String(), nil +} diff --git a/pkg/referenceutil/cid_noipfs.go b/pkg/referenceutil/cid_noipfs.go new file mode 100644 index 00000000000..d7a8228925f --- /dev/null +++ b/pkg/referenceutil/cid_noipfs.go @@ -0,0 +1,29 @@ +//go:build no_ipfs + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package referenceutil + +import ( + "fmt" + + "github.com/containerd/errdefs" +) + +func decodeCid(v string) (string, error) { + return "", fmt.Errorf("%w: ipfs is disabled by the distributor of this build", errdefs.ErrNotImplemented) +} diff --git a/pkg/referenceutil/referenceutil.go b/pkg/referenceutil/referenceutil.go index b46bb240d83..54e8f79e273 100644 --- a/pkg/referenceutil/referenceutil.go +++ b/pkg/referenceutil/referenceutil.go @@ -22,7 +22,6 @@ import ( "strings" "github.com/distribution/reference" - "github.com/ipfs/go-cid" "github.com/opencontainers/go-digest" ) @@ -108,9 +107,9 @@ func Parse(rawRef string) (*ImageReference, error) { // before parsing the image reference specified in its OCI image manifest. return nil, ErrLoadOCIArchiveRequired } - if decodedCID, err := cid.Decode(rawRef); err == nil { + if decodedCID, err := decodeCid(rawRef); err == nil { ir.Protocol = IPFSProtocol - rawRef = decodedCID.String() + rawRef = decodedCID ir.Path = rawRef return ir, nil } diff --git a/pkg/resolvconf/resolvconf.go b/pkg/resolvconf/resolvconf.go index 79bec3ecd9e..30aa53fe21c 100644 --- a/pkg/resolvconf/resolvconf.go +++ b/pkg/resolvconf/resolvconf.go @@ -36,6 +36,8 @@ import ( "sync" "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) const ( @@ -70,7 +72,7 @@ var ( // More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf func Path() string { detectSystemdResolvConfOnce.Do(func() { - candidateResolvConf, err := os.ReadFile(defaultPath) + candidateResolvConf, err := filesystem.ReadFile(defaultPath) if err != nil { // silencing error as it will resurface at next calls trying to read defaultPath return @@ -131,7 +133,7 @@ func Get() (*File, error) { // GetSpecific returns the contents of the user specified resolv.conf file and its hash func GetSpecific(path string) (*File, error) { - resolv, err := os.ReadFile(path) + resolv, err := filesystem.ReadFile(path) if err != nil { return nil, err } @@ -149,7 +151,7 @@ func GetIfChanged() (*File, error) { lastModified.Lock() defer lastModified.Unlock() - resolv, err := os.ReadFile(Path()) + resolv, err := filesystem.ReadFile(Path()) if err != nil { return nil, err } @@ -182,7 +184,23 @@ func GetLastModified() *File { // 2. Given the caller provides the enable/disable state of IPv6, the filter // code will remove all IPv6 nameservers if it is not enabled for containers func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) { - cleanedResolvConf := localhostNSRegexp.ReplaceAll(resolvConf, []byte{}) + return FilterResolvDNSWithLocalhostOption(resolvConf, ipv6Enabled, false) +} + +// FilterResolvDNSWithLocalhostOption is like FilterResolvDNS but allows controlling +// whether localhost nameservers are preserved. This is useful for host network mode +// where the container should inherit the host's DNS configuration including localhost resolvers. +// +// Parameters: +// - resolvConf: the resolv.conf file content +// - ipv6Enabled: whether IPv6 nameservers should be preserved +// - allowLocalhostDNS: if true, localhost nameservers are preserved; if false, they are filtered out +func FilterResolvDNSWithLocalhostOption(resolvConf []byte, ipv6Enabled bool, allowLocalhostDNS bool) (*File, error) { + cleanedResolvConf := resolvConf + // if allowLocalhostDNS is false, remove localhost nameservers + if !allowLocalhostDNS { + cleanedResolvConf = localhostNSRegexp.ReplaceAll(cleanedResolvConf, []byte{}) + } // if IPv6 is not enabled, also clean out any IPv6 address nameserver if !ipv6Enabled { cleanedResolvConf = nsIPv6Regexp.ReplaceAll(cleanedResolvConf, []byte{}) @@ -317,12 +335,12 @@ func Build(path string, dns, dnsSearch, dnsOptions []string) (*File, error) { return nil, err } - err = os.WriteFile(path, content.Bytes(), 0o644) + err = filesystem.WriteFile(path, content.Bytes(), 0o644) if err != nil { return nil, err } - // os.WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched + // WriteFile relies on syscall.Open. Unless there are ACLs, the effective mode of the file will be matched // against the current process umask. // See https://www.man7.org/linux/man-pages/man2/open.2.html for details. // Since we must make sure that these files are world readable, explicitly chmod them here. diff --git a/pkg/resolvconf/resolvconf_linux_test.go b/pkg/resolvconf/resolvconf_linux_test.go index 0110eb34a46..2c204e0a8a1 100644 --- a/pkg/resolvconf/resolvconf_linux_test.go +++ b/pkg/resolvconf/resolvconf_linux_test.go @@ -21,6 +21,8 @@ import ( "bytes" "os" "testing" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) func TestGet(t *testing.T) { @@ -28,7 +30,7 @@ func TestGet(t *testing.T) { if err != nil { t.Fatal(err) } - resolvConfSystem, err := os.ReadFile("/run/systemd/resolve/resolv.conf") + resolvConfSystem, err := filesystem.ReadFile("/run/systemd/resolve/resolv.conf") if err != nil { t.Fatal(err) } @@ -171,7 +173,7 @@ func TestBuild(t *testing.T) { t.Fatal(err) } - content, err := os.ReadFile(file.Name()) + content, err := filesystem.ReadFile(file.Name()) if err != nil { t.Fatal(err) } @@ -193,7 +195,7 @@ func TestBuildWithZeroLengthDomainSearch(t *testing.T) { t.Fatal(err) } - content, err := os.ReadFile(file.Name()) + content, err := filesystem.ReadFile(file.Name()) if err != nil { t.Fatal(err) } @@ -218,7 +220,7 @@ func TestBuildWithNoOptions(t *testing.T) { t.Fatal(err) } - content, err := os.ReadFile(file.Name()) + content, err := filesystem.ReadFile(file.Name()) if err != nil { t.Fatal(err) } @@ -318,3 +320,100 @@ func TestFilterResolvDns(t *testing.T) { } } } + +func TestFilterResolvDnsWithLocalhostOption(t *testing.T) { + testCases := []struct { + name string + input string + allowLocalhostDNS bool + ipv6Enabled bool + expected string + }{ + { + name: "filter_disallow_localhost_ipv6_disabled", + input: "nameserver 127.0.0.53\nnameserver 192.88.99.1\nnameserver ::1\nnameserver 2001:db8::1\n", + allowLocalhostDNS: false, + ipv6Enabled: false, + expected: "nameserver 192.88.99.1\n", + }, + { + name: "filter_allow_localhost_ipv6_disabled", + input: "nameserver 127.0.0.53\nnameserver 192.88.99.1\nnameserver ::1\nnameserver 2001:db8::1\n", + allowLocalhostDNS: true, + ipv6Enabled: false, + expected: "nameserver 127.0.0.53\nnameserver 192.88.99.1\n", + }, + { + name: "filter_disallow_localhost_ipv6_enabled", + input: "nameserver 127.0.0.53\nnameserver 192.88.99.1\nnameserver ::1\nnameserver 2001:db8::1\n", + allowLocalhostDNS: false, + ipv6Enabled: true, + expected: "nameserver 192.88.99.1\nnameserver 2001:db8::1\n", + }, + { + name: "filter_allow_localhost_ipv6_enabled", + input: "nameserver 127.0.0.53\nnameserver 192.88.99.1\nnameserver ::1\nnameserver 2001:db8::1\n", + allowLocalhostDNS: true, + ipv6Enabled: true, + expected: "nameserver 127.0.0.53\nnameserver 192.88.99.1\nnameserver ::1\nnameserver 2001:db8::1\n", + }, + { + name: "fallback_none_ipv6_disabled", + input: "", + allowLocalhostDNS: false, + ipv6Enabled: false, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4", + }, + { + name: "fallback_none_ipv6_enabled", + input: "", + allowLocalhostDNS: false, + ipv6Enabled: true, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844", + }, + { + name: "fallback_localhost4_ipv6_disabled", + input: "nameserver 127.0.0.53", + allowLocalhostDNS: false, + ipv6Enabled: false, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4", + }, + { + name: "fallback_localhost4_ipv6_enabled", + input: "nameserver 127.0.0.53", + allowLocalhostDNS: false, + ipv6Enabled: true, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844", + }, + { + name: "fallback_localhost6_ipv6_disabled", + input: "nameserver ::1", + allowLocalhostDNS: false, + ipv6Enabled: false, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4", + }, + { + name: "fallback_localhost6_ipv6_enabled", + input: "nameserver ::1", + allowLocalhostDNS: false, + ipv6Enabled: true, + expected: "\nnameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result, err := FilterResolvDNSWithLocalhostOption([]byte(tc.input), tc.ipv6Enabled, tc.allowLocalhostDNS) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("result is nil") + } + if tc.expected != string(result.Content) { + t.Fatalf("expected \n<%s> got \n<%s>", tc.expected, string(result.Content)) + } + }) + } +} diff --git a/pkg/rootlessutil/parent_linux.go b/pkg/rootlessutil/parent_linux.go index d90b9b77dee..7ae9b36c66b 100644 --- a/pkg/rootlessutil/parent_linux.go +++ b/pkg/rootlessutil/parent_linux.go @@ -27,6 +27,8 @@ import ( "syscall" "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) func IsRootlessParent() bool { @@ -55,7 +57,7 @@ func RootlessKitChildPid(stateDir string) (int, error) { return 0, err } - pidFileBytes, err := os.ReadFile(pidFilePath) + pidFileBytes, err := filesystem.ReadFile(pidFilePath) if err != nil { return 0, err } diff --git a/pkg/snapshotterutil/sociutil.go b/pkg/snapshotterutil/sociutil.go index a2148de027c..82e8773ce66 100644 --- a/pkg/snapshotterutil/sociutil.go +++ b/pkg/snapshotterutil/sociutil.go @@ -18,23 +18,29 @@ package snapshotterutil import ( "bufio" + "context" + "fmt" "os" "os/exec" + "regexp" "strconv" "strings" + "github.com/Masterminds/semver/v3" + + "github.com/containerd/containerd/v2/client" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" ) -// CreateSoci creates a SOCI index(`rawRef`) -func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string, sOpts types.SociOptions) error { +// setupSociCommand creates and sets up a SOCI command with common configuration +func setupSociCommand(gOpts types.GlobalCommandOptions) (*exec.Cmd, error) { sociExecutable, err := exec.LookPath("soci") if err != nil { log.L.WithError(err).Error("soci executable not found in path $PATH") log.L.Info("you might consider installing soci from: https://github.com/awslabs/soci-snapshotter/blob/main/docs/install.md") - return err + return nil, err } sociCmd := exec.Command(sociExecutable) @@ -47,7 +53,117 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo if gOpts.Namespace != "" { sociCmd.Args = append(sociCmd.Args, "--namespace", gOpts.Namespace) } - // #endregion + + return sociCmd, nil +} + +// CheckSociVersion checks if the SOCI binary version is at least the required version +func CheckSociVersion(requiredVersion string) error { + sociExecutable, err := exec.LookPath("soci") + if err != nil { + log.L.WithError(err).Error("soci executable not found in path $PATH") + log.L.Info("you might consider installing soci from: https://github.com/awslabs/soci-snapshotter/blob/main/docs/install.md") + return err + } + + cmd := exec.Command(sociExecutable, "--version") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to get SOCI version: %w", err) + } + + // Parse the version string + versionStr := string(output) + // Handle format like "soci version v0.10.0 8bbfe951bbb411798ee85dbd908544df4a1619a8.m" + re := regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) + matches := re.FindStringSubmatch(versionStr) + if len(matches) < 2 { + return fmt.Errorf("failed to parse SOCI version from output: %s", versionStr) + } + + // Extract version number + installedVersionStr := matches[1] + + // Parse versions using semver package + installedVersion, err := semver.NewVersion(installedVersionStr) + if err != nil { + return fmt.Errorf("failed to parse installed SOCI version: %w", err) + } + + reqVersion, err := semver.NewVersion(requiredVersion) + if err != nil { + return fmt.Errorf("failed to parse required SOCI version: %w", err) + } + + // Compare versions + if installedVersion.LessThan(reqVersion) { + return fmt.Errorf("SOCI version %s is lower than the required version %s for the convert operation", installedVersion.String(), reqVersion.String()) + } + + return nil +} + +// ConvertSociIndexV2 converts an image to SOCI format and returns the converted image reference with digest +func ConvertSociIndexV2(ctx context.Context, client *client.Client, srcRef string, destRef string, gOpts types.GlobalCommandOptions, sOpts types.SociOptions) (string, error) { + // Check if SOCI version is at least 0.10.0 which is required for the convert operation + if err := CheckSociVersion("0.10.0"); err != nil { + return "", err + } + + sociCmd, err := setupSociCommand(gOpts) + if err != nil { + return "", err + } + + sociCmd.Args = append(sociCmd.Args, "convert") + + if sOpts.AllPlatforms { + sociCmd.Args = append(sociCmd.Args, "--all-platforms") + } else if len(sOpts.Platforms) > 0 { + // multiple values need to be passed as separate, repeating flags in soci as it uses urfave + // https://github.com/urfave/cli/blob/main/docs/v2/examples/flags.md#multiple-values-per-single-flag + for _, p := range sOpts.Platforms { + sociCmd.Args = append(sociCmd.Args, "--platform", p) + } + } + + if sOpts.SpanSize != -1 { + sociCmd.Args = append(sociCmd.Args, "--span-size", strconv.FormatInt(sOpts.SpanSize, 10)) + } + + if sOpts.MinLayerSize != -1 { + sociCmd.Args = append(sociCmd.Args, "--min-layer-size", strconv.FormatInt(sOpts.MinLayerSize, 10)) + } + + sociCmd.Args = append(sociCmd.Args, srcRef, destRef) + + log.L.Infof("Converting image from %s to %s using SOCI format", srcRef, destRef) + + err = processSociIO(sociCmd) + if err != nil { + return "", err + } + err = sociCmd.Wait() + if err != nil { + return "", err + } + + // Get the converted image's digest + img, err := client.GetImage(ctx, destRef) + if err != nil { + return "", fmt.Errorf("failed to get converted image: %w", err) + } + + // Return the full reference with digest + return fmt.Sprintf("%s@%s", destRef, img.Target().Digest), nil +} + +// CreateSociIndexV1 creates a SOCI index(`rawRef`) +func CreateSociIndexV1(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string, sOpts types.SociOptions) error { + sociCmd, err := setupSociCommand(gOpts) + if err != nil { + return err + } // Global flags have to be put before subcommand before soci upgrades to urfave v3. // https://github.com/urfave/cli/issues/1113 @@ -73,7 +189,7 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo // --timeout, --debug, --content-store sociCmd.Args = append(sociCmd.Args, rawRef) - log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args) + log.L.Debugf("running soci %v", sociCmd.Args) err = processSociIO(sociCmd) if err != nil { @@ -88,25 +204,11 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo func PushSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string) error { log.L.Debugf("pushing SOCI index: %s", rawRef) - sociExecutable, err := exec.LookPath("soci") + sociCmd, err := setupSociCommand(gOpts) if err != nil { - log.L.WithError(err).Error("soci executable not found in path $PATH") - log.L.Info("you might consider installing soci from: https://github.com/awslabs/soci-snapshotter/blob/main/docs/install.md") return err } - sociCmd := exec.Command(sociExecutable) - sociCmd.Env = os.Environ() - - // #region for global flags. - if gOpts.Address != "" { - sociCmd.Args = append(sociCmd.Args, "--address", gOpts.Address) - } - if gOpts.Namespace != "" { - sociCmd.Args = append(sociCmd.Args, "--namespace", gOpts.Namespace) - } - // #endregion - // Global flags have to be put before subcommand before soci upgrades to urfave v3. // https://github.com/urfave/cli/issues/1113 sociCmd.Args = append(sociCmd.Args, "push") @@ -131,7 +233,7 @@ func PushSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, } sociCmd.Args = append(sociCmd.Args, rawRef) - log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args) + log.L.Debugf("running soci %v", sociCmd.Args) err = processSociIO(sociCmd) if err != nil { diff --git a/pkg/statsutil/stats.go b/pkg/statsutil/stats.go index c5bf3c0f68d..c61e5648a2f 100644 --- a/pkg/statsutil/stats.go +++ b/pkg/statsutil/stats.go @@ -26,6 +26,11 @@ import ( units "github.com/docker/go-units" ) +type SystemInfo struct { + OnlineCPUs uint32 + SystemUsage uint64 +} + // StatsEntry represents the statistics data collected from a container type StatsEntry struct { Name string diff --git a/pkg/statsutil/stats_linux.go b/pkg/statsutil/stats_linux.go index 4f1f53bc828..8e7f08f056b 100644 --- a/pkg/statsutil/stats_linux.go +++ b/pkg/statsutil/stats_linux.go @@ -38,8 +38,8 @@ func calculateMemPercent(limit float64, usedNo float64) float64 { return 0 } -func SetCgroupStatsFields(previousStats *ContainerStats, data *v1.Metrics, links []netlink.Link) (StatsEntry, error) { - cpuPercent := calculateCgroupCPUPercent(previousStats, data) +func SetCgroupStatsFields(previousStats *ContainerStats, data *v1.Metrics, links []netlink.Link, systemInfo SystemInfo) (StatsEntry, error) { + cpuPercent := calculateCgroupCPUPercent(previousStats, data, systemInfo) blkRead, blkWrite := calculateCgroupBlockIO(data) mem := calculateCgroupMemUsage(data) memLimit := getCgroupMemLimit(float64(data.Memory.Usage.Limit)) @@ -114,18 +114,21 @@ func getHostMemLimit() float64 { return float64(^uint64(0)) } -func calculateCgroupCPUPercent(previousStats *ContainerStats, metrics *v1.Metrics) float64 { +func calculateCgroupCPUPercent(previousStats *ContainerStats, metrics *v1.Metrics, systemInfo SystemInfo) float64 { var ( cpuPercent = 0.0 // calculate the change for the cpu usage of the container in between readings cpuDelta = float64(metrics.CPU.Usage.Total) - float64(previousStats.CgroupCPU) // calculate the change for the entire system between readings - systemDelta = float64(metrics.CPU.Usage.Kernel) - float64(previousStats.CgroupSystem) - onlineCPUs = float64(len(metrics.CPU.Usage.PerCPU)) + systemDelta = float64(systemInfo.SystemUsage) - float64(previousStats.CgroupSystem) + onlineCPUs = systemInfo.OnlineCPUs ) + if onlineCPUs == 0 { + onlineCPUs = uint32(len(metrics.CPU.Usage.PerCPU)) + } if systemDelta > 0.0 && cpuDelta > 0.0 { - cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100.0 + cpuPercent = (cpuDelta / systemDelta) * float64(onlineCPUs) * 100.0 } return cpuPercent } diff --git a/pkg/store/filestore.go b/pkg/store/filestore.go index 312155230fa..1893bfa6c64 100644 --- a/pkg/store/filestore.go +++ b/pkg/store/filestore.go @@ -21,10 +21,9 @@ import ( "fmt" "os" "path/filepath" - "strings" "sync" - "github.com/containerd/nerdctl/v2/pkg/lockutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) // TODO: implement a read-lock in lockutil, in addition to the current exclusive write-lock @@ -75,7 +74,7 @@ type fileStore struct { func (vs *fileStore) Lock() error { vs.mutex.Lock() - dirFile, err := lockutil.Lock(vs.dir) + dirFile, err := filesystem.Lock(vs.dir) if err != nil { return errors.Join(ErrLockFailure, err) } @@ -96,7 +95,7 @@ func (vs *fileStore) Release() error { vs.locked = nil }() - if err := lockutil.Unlock(vs.locked); err != nil { + if err := filesystem.Unlock(vs.locked); err != nil { return errors.Join(ErrLockFailure, err) } @@ -139,7 +138,7 @@ func (vs *fileStore) Get(key ...string) ([]byte, error) { return nil, errors.Join(ErrFaultyImplementation, fmt.Errorf("%q is a directory and cannot be read as a file", path)) } - content, err := os.ReadFile(filepath.Join(append([]string{vs.dir}, key...)...)) + content, err := filesystem.ReadFile(filepath.Join(append([]string{vs.dir}, key...)...)) if err != nil { return nil, errors.Join(ErrSystemFailure, err) } @@ -194,7 +193,11 @@ func (vs *fileStore) Set(data []byte, key ...string) error { } } - return atomicWrite(parent, fileName, vs.filePerm, data) + if err := filesystem.WriteFileWithRename(filepath.Join(parent, fileName), data, vs.filePerm); err != nil { + return errors.Join(ErrSystemFailure, err) + } + + return nil } func (vs *fileStore) List(key ...string) ([]string, error) { @@ -204,8 +207,8 @@ func (vs *fileStore) List(key ...string) ([]string, error) { // Unlike Get, Set and Delete, List can have zero length key for _, k := range key { - if err := ValidatePathComponent(k); err != nil { - return nil, err + if err := filesystem.ValidatePathComponent(k); err != nil { + return nil, errors.Join(ErrInvalidArgument, err) } } @@ -333,24 +336,6 @@ func (vs *fileStore) GroupSize(key ...string) (int64, error) { return size, nil } -// ValidatePathComponent will enforce os specific filename restrictions on a single path component -func ValidatePathComponent(pathComponent string) error { - // https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits - if len(pathComponent) > 255 { - return errors.Join(ErrInvalidArgument, errors.New("identifiers must be stricly shorter than 256 characters")) - } - - if strings.TrimSpace(pathComponent) == "" { - return errors.Join(ErrInvalidArgument, errors.New("identifier cannot be empty")) - } - - if err := validatePlatformSpecific(pathComponent); err != nil { - return errors.Join(ErrInvalidArgument, err) - } - - return nil -} - // validateAllPathComponents will enforce validation for a slice of components func validateAllPathComponents(pathComponent ...string) error { if len(pathComponent) == 0 { @@ -358,26 +343,17 @@ func validateAllPathComponents(pathComponent ...string) error { } for _, key := range pathComponent { - if err := ValidatePathComponent(key); err != nil { - return err + if err := filesystem.ValidatePathComponent(key); err != nil { + return errors.Join(ErrInvalidArgument, err) } } return nil } -func atomicWrite(parent string, fileName string, perm os.FileMode, data []byte) error { - dest := filepath.Join(parent, fileName) - temp := filepath.Join(parent, ".temp."+fileName) - - err := os.WriteFile(temp, data, perm) - if err != nil { - return errors.Join(ErrSystemFailure, err) - } - - err = os.Rename(temp, dest) - if err != nil { - return errors.Join(ErrSystemFailure, err) +func IsFilesystemSafe(identifier string) error { + if err := filesystem.ValidatePathComponent(identifier); err != nil { + return errors.Join(ErrInvalidArgument, err) } return nil diff --git a/pkg/store/filestore_test.go b/pkg/store/filestore_test.go index 58f4eebeef0..ac7c7c8b5cd 100644 --- a/pkg/store/filestore_test.go +++ b/pkg/store/filestore_test.go @@ -17,12 +17,12 @@ package store import ( - "fmt" - "runtime" "testing" "time" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) func TestFileStoreBasics(t *testing.T) { @@ -62,16 +62,16 @@ func TestFileStoreBasics(t *testing.T) { // Invalid keys _, err = tempStore.Get("..") - assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + assert.ErrorIs(t, err, filesystem.ErrInvalidPath, "unsupported characters or patterns should return filesystem.ErrInvalidPath") err = tempStore.Set([]byte("foo"), "..") - assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + assert.ErrorIs(t, err, filesystem.ErrInvalidPath, "unsupported characters or patterns should return filesystem.ErrInvalidPath") err = tempStore.Delete("..") - assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + assert.ErrorIs(t, err, filesystem.ErrInvalidPath, "unsupported characters or patterns should return filesystem.ErrInvalidPath") _, err = tempStore.List("..") - assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + assert.ErrorIs(t, err, filesystem.ErrInvalidPath, "unsupported characters or patterns should return filesystem.ErrInvalidPath") // Writing, reading, listing, deleting err = tempStore.Set([]byte("foo"), "something") @@ -220,60 +220,3 @@ func TestFileStoreConcurrent(t *testing.T) { }) assert.NilError(t, lErr, "locking should not error") } - -func TestFileStoreFilesystemRestrictions(t *testing.T) { - invalid := []string{ - "/", - "/start", - "mid/dle", - "end/", - ".", - "..", - "", - fmt.Sprintf("A%0255s", "A"), - } - - valid := []string{ - fmt.Sprintf("A%0254s", "A"), - "test", - "test-hyphen", - ".start.dot", - "mid.dot", - "∞", - } - - if runtime.GOOS == "windows" { - invalid = append(invalid, []string{ - "\\start", - "mid\\dle", - "end\\", - "\\", - "\\.", - "com².whatever", - "lpT2", - "Prn.", - "nUl", - "AUX", - "AA", - "A:A", - "A\"A", - "A|A", - "A?A", - "A*A", - "end.dot.", - "end.space ", - }...) - } - - for _, v := range invalid { - err := ValidatePathComponent(v) - assert.ErrorIs(t, err, ErrInvalidArgument, v) - } - - for _, v := range valid { - err := ValidatePathComponent(v) - assert.NilError(t, err, v) - } - -} diff --git a/pkg/taskutil/taskutil.go b/pkg/taskutil/taskutil.go index 6e978aa6a5a..633c188cf73 100644 --- a/pkg/taskutil/taskutil.go +++ b/pkg/taskutil/taskutil.go @@ -19,6 +19,7 @@ package taskutil import ( "context" "errors" + "fmt" "io" "net/url" "os" @@ -27,28 +28,89 @@ import ( "strings" "sync" "syscall" + "time" "github.com/Masterminds/semver/v3" + "github.com/opencontainers/go-digest" "golang.org/x/term" "github.com/containerd/console" + "github.com/containerd/containerd/api/types" containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/archive" "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/cioutil" "github.com/containerd/nerdctl/v2/pkg/consoleutil" - "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" ) +// TaskOptions contains options for creating a new task +type TaskOptions struct { + AttachStreamOpt []string + IsInteractive bool + IsTerminal bool + IsDetach bool + Con console.Console + LogURI string + DetachKeys string + Namespace string + DetachC chan<- struct{} + CheckpointDir string +} + // NewTask is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/tasks/tasks_unix.go#L70-L108 -func NewTask(ctx context.Context, client *containerd.Client, container containerd.Container, - attachStreamOpt []string, flagI, flagT, flagD bool, con console.Console, logURI, detachKeys, namespace string, detachC chan<- struct{}) (containerd.Task, error) { +func NewTask(ctx context.Context, client *containerd.Client, container containerd.Container, opts TaskOptions) (containerd.Task, error) { + var ( + checkpoint *types.Descriptor + t containerd.Task + err error + ) - var t containerd.Task + if opts.CheckpointDir != "" { + tar := archive.Diff(ctx, "", opts.CheckpointDir) + cs := client.ContentStore() + writer, err := cs.Writer(ctx, content.WithRef(opts.CheckpointDir)) + if err != nil { + return nil, err + } + defer writer.Close() + size, err := io.Copy(writer, tar) + if err != nil { + return nil, err + } + labels := map[string]string{ + "containerd.io/gc.root": time.Now().UTC().Format(time.RFC3339), + } + if err = writer.Commit(ctx, size, "", content.WithLabels(labels)); err != nil { + if !errors.Is(err, errdefs.ErrAlreadyExists) { + return nil, err + } + } + checkpoint = &types.Descriptor{ + MediaType: images.MediaTypeContainerd1Checkpoint, + Digest: writer.Digest().String(), + Size: size, + } + defer func() { + if checkpoint != nil { + _ = cs.Delete(ctx, digest.Digest(checkpoint.Digest)) + } + }() + if err = tar.Close(); err != nil { + return nil, fmt.Errorf("failed to close checkpoint tar stream: %w", err) + } + if err != nil { + return nil, fmt.Errorf("failed to upload checkpoint to containerd: %w", err) + } + } closer := func() { - if detachC != nil { - detachC <- struct{}{} + if opts.DetachC != nil { + opts.DetachC <- struct{}{} } // t will be set by container.NewTask at the end of this function. // @@ -64,30 +126,30 @@ func NewTask(ctx context.Context, client *containerd.Client, container container io.Cancel() } var ioCreator cio.Creator - if len(attachStreamOpt) != 0 { + if len(opts.AttachStreamOpt) != 0 { log.G(ctx).Debug("attaching output instead of using the log-uri") // when attaching a TTY we use writee for stdio and binary for log persistence - if flagT { + if opts.IsTerminal { var in io.Reader - if flagI { + if opts.IsInteractive { // FIXME: check IsTerminal on Windows too if runtime.GOOS != "windows" && !term.IsTerminal(0) { return nil, errors.New("the input device is not a TTY") } var err error - in, err = consoleutil.NewDetachableStdin(con, detachKeys, closer) + in, err = consoleutil.NewDetachableStdin(opts.Con, opts.DetachKeys, closer) if err != nil { return nil, err } } - ioCreator = cioutil.NewContainerIO(namespace, logURI, true, in, con, nil) + ioCreator = cioutil.NewContainerIO(opts.Namespace, opts.LogURI, true, in, opts.Con, nil) } else { - streams := processAttachStreamsOpt(attachStreamOpt) - ioCreator = cioutil.NewContainerIO(namespace, logURI, false, streams.stdIn, streams.stdOut, streams.stdErr) + streams := processAttachStreamsOpt(opts.AttachStreamOpt) + ioCreator = cioutil.NewContainerIO(opts.Namespace, opts.LogURI, false, streams.stdIn, streams.stdOut, streams.stdErr) } - } else if flagT && flagD { - u, err := url.Parse(logURI) + } else if opts.IsTerminal && opts.IsDetach { + u, err := url.Parse(opts.LogURI) if err != nil { return nil, err } @@ -113,33 +175,33 @@ func NewTask(ctx context.Context, client *containerd.Client, container container ioCreator = cio.TerminalBinaryIO(parsedPath, map[string]string{ args[0]: args[1], }) - } else if flagT && !flagD { - if con == nil { - return nil, errors.New("got nil con with flagT=true") + } else if opts.IsTerminal && !opts.IsDetach { + if opts.Con == nil { + return nil, errors.New("got nil con with isTerminal=true") } var in io.Reader - if flagI { + if opts.IsInteractive { // FIXME: check IsTerminal on Windows too if runtime.GOOS != "windows" && !term.IsTerminal(0) { return nil, errors.New("the input device is not a TTY") } var err error - in, err = consoleutil.NewDetachableStdin(con, detachKeys, closer) + in, err = consoleutil.NewDetachableStdin(opts.Con, opts.DetachKeys, closer) if err != nil { return nil, err } } - ioCreator = cioutil.NewContainerIO(namespace, logURI, true, in, os.Stdout, os.Stderr) - } else if flagD && logURI != "" && logURI != "none" { - u, err := url.Parse(logURI) + ioCreator = cioutil.NewContainerIO(opts.Namespace, opts.LogURI, true, in, os.Stdout, os.Stderr) + } else if opts.IsDetach && opts.LogURI != "" && opts.LogURI != "none" { + u, err := url.Parse(opts.LogURI) if err != nil { return nil, err } ioCreator = cio.LogURI(u) } else { var in io.Reader - if flagI { - if sv, err := infoutil.ServerSemVer(ctx, client); err != nil { + if opts.IsInteractive { + if sv, err := containerdutil.ServerSemVer(ctx, client); err != nil { log.G(ctx).Warn(err) } else if sv.LessThan(semver.MustParse("1.6.0-0")) { log.G(ctx).Warnf("`nerdctl (run|exec) -i` without `-t` expects containerd 1.6 or later, got containerd %v", sv) @@ -156,9 +218,17 @@ func NewTask(ctx context.Context, client *containerd.Client, container container } in = stdinC } - ioCreator = cioutil.NewContainerIO(namespace, logURI, false, in, os.Stdout, os.Stderr) + ioCreator = cioutil.NewContainerIO(opts.Namespace, opts.LogURI, false, in, os.Stdout, os.Stderr) } - t, err := container.NewTask(ctx, ioCreator) + + taskOpts := []containerd.NewTaskOpts{ + func(_ context.Context, _ *containerd.Client, info *containerd.TaskInfo) error { + info.Checkpoint = checkpoint + return nil + }, + } + + t, err = container.NewTask(ctx, ioCreator, taskOpts...) if err != nil { return nil, err } diff --git a/pkg/testutil/compose.go b/pkg/testutil/compose.go index 2e5b55b056d..e247dcc7d60 100644 --- a/pkg/testutil/compose.go +++ b/pkg/testutil/compose.go @@ -18,23 +18,28 @@ package testutil import ( "context" + "fmt" "os" "path/filepath" - "testing" "github.com/compose-spec/compose-go/v2/loader" compose "github.com/compose-spec/compose-go/v2/types" + + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" ) type ComposeDir struct { - t testing.TB + t tig.T dir string yamlBasePath string } func (cd *ComposeDir) WriteFile(name, content string) { - if err := os.WriteFile(filepath.Join(cd.dir, name), []byte(content), 0644); err != nil { - cd.t.Fatal(err) + if err := filesystem.WriteFile(filepath.Join(cd.dir, name), []byte(content), 0644); err != nil { + cd.t.Log(fmt.Sprintf("Failed to create file %v", err)) + cd.t.FailNow() } } @@ -54,10 +59,11 @@ func (cd *ComposeDir) CleanUp() { os.RemoveAll(cd.dir) } -func NewComposeDir(t testing.TB, dockerComposeYAML string) *ComposeDir { +func NewComposeDir(t tig.T, dockerComposeYAML string) *ComposeDir { tmpDir, err := os.MkdirTemp("", "nerdctl-compose-test") if err != nil { - t.Fatal(err) + t.Log(fmt.Sprintf("Failed to create temp dir: %v", err)) + t.FailNow() } cd := &ComposeDir{ t: t, @@ -73,7 +79,7 @@ func LoadProject(fileName, projectName string, envMap map[string]string) (*compo if envMap == nil { envMap = make(map[string]string) } - b, err := os.ReadFile(fileName) + b, err := filesystem.ReadFile(fileName) if err != nil { return nil, err } diff --git a/pkg/testutil/images.yaml b/pkg/testutil/images.yaml new file mode 100644 index 00000000000..089273231e7 --- /dev/null +++ b/pkg/testutil/images.yaml @@ -0,0 +1,91 @@ +# Current schema (defined in images_linux.go) allows for ref, tag, (index) digest and platform variants. +# Right now, digest and variants are not used for anything, but they should / could be in the future. +# Also note that changing the schema should be easy and straight-forward for now, so, +# this might evolve in the near future. +alpine: + ref: "ghcr.io/stargz-containers/alpine" + tag: "3.13-org" + schemaversion: 2 + mediatype: "application/vnd.docker.distribution.manifest.list.v2+json" + digest: "sha256:ec14c7992a97fc11425907e908340c6c3d6ff602f5f13d899e6b7027c9b4133a" + variants: ["linux/amd64", "linux/arm64"] + manifests: + linux/amd64: + mediatype: "application/vnd.docker.distribution.manifest.v2+json" + manifest: "sha256:e103c1b4bf019dc290bcc7aca538dc2bf7a9d0fc836e186f5fa34945c5168310" + config: "sha256:49f356fa4513676c5e22e3a8404aad6c7262cc7aaed15341458265320786c58c" + raw: "ewogICAic2NoZW1hVmVyc2lvbiI6IDIsCiAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5kaXN0cmlidXRpb24ubWFuaWZlc3QudjIranNvbiIsCiAgICJjb25maWciOiB7CiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5jb250YWluZXIuaW1hZ2UudjEranNvbiIsCiAgICAgICJzaXplIjogMTQ3MiwKICAgICAgImRpZ2VzdCI6ICJzaGEyNTY6NDlmMzU2ZmE0NTEzNjc2YzVlMjJlM2E4NDA0YWFkNmM3MjYyY2M3YWFlZDE1MzQxNDU4MjY1MzIwNzg2YzU4YyIKICAgfSwKICAgImxheWVycyI6IFsKICAgICAgewogICAgICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLAogICAgICAgICAic2l6ZSI6IDI4MTE5NDcsCiAgICAgICAgICJkaWdlc3QiOiAic2hhMjU2OmNhM2NkNDJhN2M5NTI1ZjZjZTNkNjRjMWE3MDk4MjYxM2E4MjM1ZjBjYzA1N2VjOTI0NDA1MjkyMTg1M2VmMTUiCiAgICAgIH0KICAgXQp9" + linux/arm64: + manifest: "sha256:071fa5de01a240dbef5be09d69f8fef2f89d68445d9175393773ee389b6f5935" + +busybox: + ref: "ghcr.io/containerd/busybox" + tag: "1.36" + +docker_auth: + ref: "ghcr.io/stargz-containers/cesanta/docker_auth" + tag: "1.7-org" + +fluentd: + ref: "fluentd" + tag: "v1.18.0-debian-1.0" + +golang: + ref: "golang" + tag: "1.25.0-trixie" + +kubo: + ref: "ghcr.io/stargz-containers/ipfs/kubo" + tag: "v0.16.0-org" + +mariadb: + ref: "ghcr.io/stargz-containers/mariadb" + tag: "10.5-org" + +nanoserver: + ref: "mcr.microsoft.com/windows/nanoserver" + tag: "ltsc2022" + +nginx: + ref: "ghcr.io/stargz-containers/nginx" + tag: "1.19-alpine-org" + +registry: + ref: "ghcr.io/stargz-containers/registry" + tag: "2-org" + +stargz: + ref: "ghcr.io/containerd/stargz-snapshotter" + tag: "0.15.1-kind" + +wordpress: + ref: "ghcr.io/stargz-containers/wordpress" + tag: "5.7-org" + +fedora_esgz: + ref: "ghcr.io/stargz-containers/fedora" + tag: "30-esgz" + +ffmpeg_soci: + ref: "public.ecr.aws/soci-workshop-examples/ffmpeg" + tag: "latest" + +# Large enough for testing soci index creation +ubuntu: + ref: "public.ecr.aws/docker/library/ubuntu" + tag: "23.10" + +coredns: + ref: "public.ecr.aws/eks-distro/coredns/coredns" + tag: "v1.12.2-eks-1-31-latest" + +# Future: images to add or update soon. +# busybox:1.37.0@sha256:37f7b378a29ceb4c551b1b5582e27747b855bbfaa73fa11914fe0df028dc581f +# debian:bookworm-slim@sha256:b1211f6d19afd012477bd34fdcabb6b663d680e0f4b0537da6e6b0fd057a3ec3 +# gitlab/gitlab-ee:17.11.0-ee.0@sha256:e0d9d5e0d0068f4b4bac3e15eb48313b5c3bb508425645f421bf2773a964c4ae +# bitnami/harbor-portal:v2.13.0@sha256:636f39610b359369aeeddd7859cb56274d9a1bc3e467e21d74ea89e1516c1a0c +# mariadb:11.7.2@sha256:81e893032978c4bf8ad43710b7a979774ed90787fa32d199162148ce28fe3b76 +# nginx:alpine3.21@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 +# wordpress:6.8.0-php8.4-fpm-alpine@sha256:309b64fa4266d8a3fe6f0973ae3172fec1023c9b18242ccf1dffbff5dc8b81a8 +# Right now, v3 is breaking tests. +# ghcr.io/distribution/distribution:3.0.0@sha256:4ba3adf47f5c866e9a29288c758c5328ef03396cb8f5f6454463655fa8bc83e2 diff --git a/pkg/testutil/images_linux.go b/pkg/testutil/images_linux.go new file mode 100644 index 00000000000..c566e6274e5 --- /dev/null +++ b/pkg/testutil/images_linux.go @@ -0,0 +1,126 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package testutil + +import ( + _ "embed" + "fmt" + "sync" + + "go.yaml.in/yaml/v3" +) + +//go:embed images.yaml +var rawImagesList string + +var testImagesOnce sync.Once + +type manifestInfo struct { + Config string `yaml:"config,omitempty"` + Manifest string `yaml:"manifest,omitempty"` + MediaType string `yaml:"mediatype,omitempty"` + Raw string `yaml:"raw,omitempty"` +} + +type TestImage struct { + Ref string `yaml:"ref"` + Tag string `yaml:"tag,omitempty"` + SchemaVersion int `yaml:"schemaversion,omitempty"` + MediaType string `yaml:"mediatype,omitempty"` + Digest string `yaml:"digest,omitempty"` + Variants []string `yaml:"variants,omitempty"` + Manifests map[string]manifestInfo `yaml:"manifests,omitempty"` +} + +var testImages map[string]TestImage + +// internal helper to lookup TestImage by key, panics if not found +func lookup(key string) TestImage { + testImagesOnce.Do(func() { + if err := yaml.Unmarshal([]byte(rawImagesList), &testImages); err != nil { + fmt.Printf("Error unmarshaling test images YAML file: %v\n", err) + panic("testing is broken") + } + }) + im, ok := testImages[key] + if !ok { + fmt.Printf("Image %s was not found in images list\n", key) + panic("testing is broken") + } + return im +} + +func GetTestImage(key string) string { + im := lookup(key) + return im.Ref + ":" + im.Tag +} + +func GetTestImageWithoutTag(key string) string { + im := lookup(key) + return im.Ref +} + +func GetTestImageConfigDigest(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Config +} + +func GetTestImageManifestDigest(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Manifest +} + +func GetTestImageDigest(key string) string { + im := lookup(key) + return im.Digest +} + +func GetTestImageMediaType(key string) string { + im := lookup(key) + return im.MediaType +} + +func GetTestImageSchemaVersion(key string) int { + im := lookup(key) + return im.SchemaVersion +} + +func GetTestImagePlatformMediaType(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.MediaType +} + +func GetTestImageRaw(key, platform string) string { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.Raw +} diff --git a/pkg/testutil/nerdtest/command.go b/pkg/testutil/nerdtest/command.go index e886514933f..6dbad324347 100644 --- a/pkg/testutil/nerdtest/command.go +++ b/pkg/testutil/nerdtest/command.go @@ -17,16 +17,18 @@ package nerdtest import ( + "fmt" "os" "os/exec" "path/filepath" "strings" - "testing" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" @@ -48,7 +50,7 @@ func isTargetNerdish() bool { return !strings.HasPrefix(filepath.Base(testutil.GetTarget()), "docker") } -func newNerdCommand(conf test.Config, t *testing.T) *nerdCommand { +func newNerdCommand(conf test.Config, t tig.T) *nerdCommand { // Decide what binary we are running var err error var binary string @@ -56,7 +58,8 @@ func newNerdCommand(conf test.Config, t *testing.T) *nerdCommand { binary, err = exec.LookPath(trgt) if err != nil { - t.Fatalf("unable to find binary %q: %v", trgt, err) + t.Log(fmt.Sprintf("unable to find binary %q: %v", trgt, err)) + t.FailNow() } if isTargetNerdish() { @@ -68,7 +71,8 @@ func newNerdCommand(conf test.Config, t *testing.T) *nerdCommand { } } else { if err = exec.Command(binary, "compose", "version").Run(); err != nil { - t.Fatalf("docker does not support compose: %v", err) + t.Log(fmt.Sprintf("docker does not support compose: %v", err)) + t.FailNow() } } @@ -126,7 +130,7 @@ func (nc *nerdCommand) prep() { if customDCConfig := nc.GenericCommand.Config.Read(DockerConfig); customDCConfig != "" { if !nc.hasWrittenDockerConfig { dest := filepath.Join(nc.Env["DOCKER_CONFIG"], "config.json") - err := os.WriteFile(dest, []byte(customDCConfig), test.FilePermissionsDefault) + err := filesystem.WriteFile(dest, []byte(customDCConfig), test.FilePermissionsDefault) assert.NilError(nc.T(), err, "failed to write custom docker config json file for test") nc.hasWrittenDockerConfig = true } @@ -175,7 +179,7 @@ func (nc *nerdCommand) prep() { if nc.Config.Read(NerdctlToml) != "" { if !nc.hasWrittenToml { dest := nc.Env["NERDCTL_TOML"] - err := os.WriteFile(dest, []byte(nc.Config.Read(NerdctlToml)), test.FilePermissionsDefault) + err := filesystem.WriteFile(dest, []byte(nc.Config.Read(NerdctlToml)), test.FilePermissionsDefault) assert.NilError(nc.T(), err, "failed to write NerdctlToml") nc.hasWrittenToml = true } diff --git a/pkg/testutil/nerdtest/platform/platform_darwin.go b/pkg/testutil/nerdtest/platform/platform_darwin.go index 0fa050fe63f..9da2ff17a7d 100644 --- a/pkg/testutil/nerdtest/platform/platform_darwin.go +++ b/pkg/testutil/nerdtest/platform/platform_darwin.go @@ -23,7 +23,6 @@ func DataHome() (string, error) { var ( // The following are here solely for darwin to compile / lint. They are not used, as the corresponding tests are running only on linux. RegistryImageStable = "registry:2" - RegistryImageNext = "ghcr.io/distribution/distribution:" KuboImage = "ipfs/kubo:v0.16.0" DockerAuthImage = "cesanta/docker_auth:1.7" ) diff --git a/pkg/testutil/nerdtest/platform/platform_freebsd.go b/pkg/testutil/nerdtest/platform/platform_freebsd.go index 8128c930167..604d2937705 100644 --- a/pkg/testutil/nerdtest/platform/platform_freebsd.go +++ b/pkg/testutil/nerdtest/platform/platform_freebsd.go @@ -23,7 +23,6 @@ func DataHome() (string, error) { var ( // The following are here solely for freebsd to compile / lint. They are not used, as the corresponding tests are running only on linux. RegistryImageStable = "registry:2" - RegistryImageNext = "ghcr.io/distribution/distribution:" KuboImage = "ipfs/kubo:v0.16.0" DockerAuthImage = "cesanta/docker_auth:1.7" ) diff --git a/pkg/testutil/nerdtest/platform/platform_linux.go b/pkg/testutil/nerdtest/platform/platform_linux.go index 3aeeb0f03c8..57d1b04bec9 100644 --- a/pkg/testutil/nerdtest/platform/platform_linux.go +++ b/pkg/testutil/nerdtest/platform/platform_linux.go @@ -27,7 +27,6 @@ func DataHome() (string, error) { var ( RegistryImageStable = testutil.RegistryImageStable - RegistryImageNext = testutil.RegistryImageNext KuboImage = testutil.KuboImage DockerAuthImage = testutil.DockerAuthImage ) diff --git a/pkg/testutil/nerdtest/platform/platform_windows.go b/pkg/testutil/nerdtest/platform/platform_windows.go index 56be8501931..2b9c07fc937 100644 --- a/pkg/testutil/nerdtest/platform/platform_windows.go +++ b/pkg/testutil/nerdtest/platform/platform_windows.go @@ -16,22 +16,12 @@ package platform -import ( - "fmt" -) - func DataHome() (string, error) { panic("not supported") } -// The following are here solely for windows to compile. They are not used, as the corresponding tests are running only on linux. -func mirrorOf(s string) string { - return fmt.Sprintf("ghcr.io/stargz-containers/%s-org", s) -} - var ( - RegistryImageStable = mirrorOf("registry:2") - RegistryImageNext = "ghcr.io/distribution/distribution:" - KuboImage = mirrorOf("ipfs/kubo:v0.16.0") - DockerAuthImage = mirrorOf("cesanta/docker_auth:1.7") + RegistryImageStable = "there-is-no-such-test-on-windows" + KuboImage = "there-is-no-such-test-on-windows" + DockerAuthImage = "there-is-no-such-test-on-windows" ) diff --git a/pkg/testutil/nerdtest/registry/cesanta.go b/pkg/testutil/nerdtest/registry/cesanta.go index 1a83f73dfcb..195fcd40c98 100644 --- a/pkg/testutil/nerdtest/registry/cesanta.go +++ b/pkg/testutil/nerdtest/registry/cesanta.go @@ -22,15 +22,15 @@ import ( "net" "os" "strconv" - "testing" "time" + "go.yaml.in/yaml/v3" "golang.org/x/crypto/bcrypt" - "gopkg.in/yaml.v3" "gotest.tools/v3/assert" "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" "github.com/containerd/nerdctl/mod/tigron/utils/testca" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" @@ -95,13 +95,13 @@ func ensureContainerStarted(helpers test.Helpers, con string) { helpers.Command("container", "inspect", con). Run(&test.Expected{ ExitCode: expect.ExitCodeNoCheck, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Container err := json.Unmarshal([]byte(stdout), &dc) if err != nil || len(dc) == 0 { return } - assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n"+info) + assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n") started = dc[0].State.Running }, }) @@ -115,7 +115,8 @@ func ensureContainerStarted(helpers test.Helpers, con string) { helpers.T().Log(ins) helpers.T().Log(lgs) helpers.T().Log(ps) - helpers.T().Fatalf("container %s still not running after %d retries", con, 5) + helpers.T().Log(fmt.Sprintf("container %s still not running after %d retries", con, 5)) + helpers.T().FailNow() } } @@ -178,7 +179,7 @@ func NewCesantaAuthServer(data test.Data, helpers test.Helpers, ca *testca.Cert, helpers.Ensure("rm", "-f", containerName) errPortRelease := portlock.Release(port) if errPortRelease != nil { - helpers.T().Error(errPortRelease.Error()) + helpers.T().Log(fmt.Sprintf("Failed to release port %d: %s", port, errPortRelease)) } } @@ -200,7 +201,7 @@ func NewCesantaAuthServer(data test.Data, helpers test.Helpers, ca *testca.Cert, scheme, net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), ), - 10, + 5, true) assert.NilError(helpers.T(), err, fmt.Errorf("failed starting auth container in a timely manner: %w", err)) @@ -218,7 +219,7 @@ func NewCesantaAuthServer(data test.Data, helpers test.Helpers, ca *testca.Cert, Setup: setup, Cleanup: cleanup, Logs: func(data test.Data, helpers test.Helpers) { - helpers.T().Error(helpers.Err("logs", containerName)) + helpers.T().Log(helpers.Err("logs", containerName)) }, } } diff --git a/pkg/testutil/nerdtest/registry/docker.go b/pkg/testutil/nerdtest/registry/docker.go index 6e90cdfcfc9..6824d19b581 100644 --- a/pkg/testutil/nerdtest/registry/docker.go +++ b/pkg/testutil/nerdtest/registry/docker.go @@ -19,7 +19,6 @@ package registry import ( "fmt" "net" - "os" "strconv" "gotest.tools/v3/assert" @@ -71,15 +70,7 @@ func NewDockerRegistry(data test.Data, helpers test.Helpers, currentCA *testca.C // Attach authentication params returns by authenticator args = append(args, auth.Params(data)...) - // Get the right registry version registryImage := platform.RegistryImageStable - up := os.Getenv("DISTRIBUTION_VERSION") - if up != "" { - if up[0:1] != "v" { - up = "v" + up - } - registryImage = platform.RegistryImageNext + up - } args = append(args, registryImage) cleanup := func(data test.Data, helpers test.Helpers) { @@ -132,7 +123,7 @@ func NewDockerRegistry(data test.Data, helpers test.Helpers, currentCA *testca.C scheme, net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), ), - 10, + 5, true) assert.NilError(helpers.T(), err, fmt.Errorf("failed starting docker registry in a timely manner: %w", err)) } @@ -144,7 +135,7 @@ func NewDockerRegistry(data test.Data, helpers test.Helpers, currentCA *testca.C Cleanup: cleanup, Setup: setup, Logs: func(data test.Data, helpers test.Helpers) { - helpers.T().Error(helpers.Err("logs", containerName)) + helpers.T().Log(helpers.Err("logs", containerName)) }, HostsDir: hostsDir, } diff --git a/pkg/testutil/nerdtest/registry/kubo.go b/pkg/testutil/nerdtest/registry/kubo.go index 40c0f67f798..1eda4c052d0 100644 --- a/pkg/testutil/nerdtest/registry/kubo.go +++ b/pkg/testutil/nerdtest/registry/kubo.go @@ -72,7 +72,7 @@ func NewKuboRegistry(data test.Data, helpers test.Helpers, t *testing.T, current scheme, net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), ), - 30, + 5, true) logs := helpers.Capture("logs", containerName) assert.NilError(t, err, fmt.Errorf("failed starting kubo registry in a timely manner: %w - logs: %s", err, logs)) @@ -85,7 +85,7 @@ func NewKuboRegistry(data test.Data, helpers test.Helpers, t *testing.T, current Cleanup: cleanup, Setup: setup, Logs: func(data test.Data, helpers test.Helpers) { - helpers.T().Error(helpers.Err("logs", containerName)) + helpers.T().Log(helpers.Err("logs", containerName)) }, } } diff --git a/pkg/testutil/nerdtest/requirements.go b/pkg/testutil/nerdtest/requirements.go index 237b349988b..8e2c0a0e258 100644 --- a/pkg/testutil/nerdtest/requirements.go +++ b/pkg/testutil/nerdtest/requirements.go @@ -20,10 +20,11 @@ import ( "context" "encoding/json" "fmt" - "os" "os/exec" + "path/filepath" "strings" + "github.com/Masterminds/semver/v3" "gotest.tools/v3/assert" "github.com/containerd/containerd/v2/defaults" @@ -32,8 +33,12 @@ import ( "github.com/containerd/nerdctl/v2/pkg/buildkitutil" "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" + ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/netutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/snapshotterutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" ) @@ -156,9 +161,45 @@ var Rootless = &test.Requirement{ }, } +// RootlessWithDetachNetNS marks a test as suitable only for rootless environment with detached netns support. +var RootlessWithDetachNetNS = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ns, err := rootlessutil.DetachedNetNS() + if err != nil { + return false, fmt.Sprintf("failed to check for detached netns: %+v", err) + } + if ns == "" { + return false, "detached netns is not supported" + } + return true, "detached netns is supported" + }, +} + +// RootlessWithoutDetachNetNS marks a test as suitable only for rootless environment without detached netns support. +// i.e., RootlessKit v1. +var RootlessWithoutDetachNetNS = require.All(Rootless, require.Not(RootlessWithDetachNetNS)) + // Rootful marks a test as suitable only for rootful env var Rootful = require.Not(Rootless) +// Info requires that `nerdctl info` satisfies the condition function passed as argument. +func Info(f func(dockercompat.Info) error) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + stdout := helpers.Capture("info", "--format", "{{ json . }}") + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + if err != nil { + return false, fmt.Sprintf("failed to parse docker info: %v", err) + } + if err := f(dinf); err != nil { + return false, err.Error() + } + return true, "" + }, + } +} + // CGroup requires that cgroup is enabled var CGroup = &test.Requirement{ Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { @@ -275,13 +316,6 @@ var Registry = require.All( // - when we start a large number of registries in subtests, no need to round-trip to ghcr everytime // This of course assumes that the subtests are NOT going to prune / rmi images registryImage := platform.RegistryImageStable - up := os.Getenv("DISTRIBUTION_VERSION") - if up != "" { - if up[0:1] != "v" { - up = "v" + up - } - registryImage = platform.RegistryImageNext + up - } helpers.Ensure("pull", "--quiet", registryImage) helpers.Ensure("pull", "--quiet", platform.DockerAuthImage) helpers.Ensure("pull", "--quiet", platform.KuboImage) @@ -303,7 +337,12 @@ var Build = &test.Requirement{ mess := "buildkitd is enabled" if isTargetNerdish() { - bkHostAddr, err := buildkitutil.GetBuildkitHost(defaultNamespace) + namespace := defaultNamespace + if ns := helpers.Read(Namespace); ns != "" { + namespace = string(ns) + } + + bkHostAddr, err := buildkitutil.GetBuildkitHost(namespace) if err != nil { ret = false mess = fmt.Sprintf("buildkitd is not enabled: %+v", err) @@ -416,3 +455,59 @@ var RemapIDs = &test.Requirement{ return false, "snapshotter does not support ID remapping" }, } + +// SociVersion returns a requirement that checks if the installed SOCI version +// meets the minimum required version +func SociVersion(minVersion string) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + // Use the common CheckSociVersion function from snapshotterutil + err := snapshotterutil.CheckSociVersion(minVersion) + if err != nil { + return false, err.Error() + } + return true, fmt.Sprintf("soci version meets minimum requirement %s", minVersion) + }, + } +} + +func ContainerdVersion(v string) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + ctx := context.Background() + namespace := defaultNamespace + address := defaults.DefaultAddress + client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address) + if err != nil { + return false, fmt.Sprintf("failed to create client: %v", err) + } + defer cancel() + if sv, err := containerdutil.ServerSemVer(ctx, client); err != nil { + return false, err.Error() + } else if sv.LessThan(semver.MustParse(v)) { + return false, fmt.Sprintf("`nerdctl commit --compression expects containerd %s or later, got containerd %v", v, sv) + } + return true, "" + }, + } +} + +// CNIFirewallVersion checks if the CNI firewall plugin version is greater than or equal to the specified version +func CNIFirewallVersion(requiredVersion string) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + cniPath := ncdefaults.CNIPath() + firewallPath := filepath.Join(cniPath, "firewall") + ok, err := netutil.FirewallPluginGEQVersion(firewallPath, requiredVersion) + if err != nil { + return false, fmt.Sprintf("Failed to check CNI firewall version: %v", err) + } + + if !ok { + return false, fmt.Sprintf("CNI firewall plugin version is less than required version %s", requiredVersion) + } + + return true, fmt.Sprintf("CNI firewall plugin version is greater than or equal to required version %s", requiredVersion) + }, + } +} diff --git a/pkg/testutil/nerdtest/test.go b/pkg/testutil/nerdtest/test.go index 94a42c459de..f9ef3321f91 100644 --- a/pkg/testutil/nerdtest/test.go +++ b/pkg/testutil/nerdtest/test.go @@ -17,9 +17,8 @@ package nerdtest import ( - "testing" - "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" ) var DockerConfig test.ConfigKey = "DockerConfig" @@ -39,11 +38,11 @@ func Setup() *test.Case { type nerdctlSetup struct { } -func (ns *nerdctlSetup) CustomCommand(testCase *test.Case, t *testing.T) test.CustomizableCommand { +func (ns *nerdctlSetup) CustomCommand(testCase *test.Case, t tig.T) test.CustomizableCommand { return newNerdCommand(testCase.Config, t) } -func (ns *nerdctlSetup) AmbientRequirements(testCase *test.Case, t *testing.T) { +func (ns *nerdctlSetup) AmbientRequirements(testCase *test.Case, t tig.T) { // Ambient requirements, bail out now if these do not match if environmentHasIPv6() && testCase.Config.Read(ipv6) != only { t.Skip("runner skips non-IPv6 compatible tests in the IPv6 environment") diff --git a/pkg/testutil/nerdtest/utilities.go b/pkg/testutil/nerdtest/utilities.go index 6a52c4afe73..a012aed201f 100644 --- a/pkg/testutil/nerdtest/utilities.go +++ b/pkg/testutil/nerdtest/utilities.go @@ -18,10 +18,10 @@ package nerdtest import ( "encoding/json" + "fmt" "net" "path/filepath" "strings" - "testing" "time" "gotest.tools/v3/assert" @@ -54,7 +54,7 @@ func InspectContainer(helpers test.Helpers, name string) dockercompat.Container var res dockercompat.Container cmd := helpers.Command("container", "inspect", name) cmd.Run(&test.Expected{ - Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, _ string, t tig.T) { + Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) { assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results") res = dc[0] }), @@ -67,7 +67,7 @@ func InspectVolume(helpers test.Helpers, name string) native.Volume { var res native.Volume cmd := helpers.Command("volume", "inspect", name) cmd.Run(&test.Expected{ - Output: expect.JSON([]native.Volume{}, func(dc []native.Volume, _ string, t tig.T) { + Output: expect.JSON([]native.Volume{}, func(dc []native.Volume, t tig.T) { assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results") res = dc[0] }), @@ -80,7 +80,20 @@ func InspectNetwork(helpers test.Helpers, name string) dockercompat.Network { var res dockercompat.Network cmd := helpers.Command("network", "inspect", name) cmd.Run(&test.Expected{ - Output: expect.JSON([]dockercompat.Network{}, func(dc []dockercompat.Network, _ string, t tig.T) { + Output: expect.JSON([]dockercompat.Network{}, func(dc []dockercompat.Network, t tig.T) { + assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results") + res = dc[0] + }), + }) + return res +} + +func InspectNetworkNative(helpers test.Helpers, name string) native.Network { + helpers.T().Helper() + var res native.Network + cmd := helpers.Command("network", "inspect", "--mode", "native", name) + cmd.Run(&test.Expected{ + Output: expect.JSON([]native.Network{}, func(dc []native.Network, t tig.T) { assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results") res = dc[0] }), @@ -93,7 +106,7 @@ func InspectImage(helpers test.Helpers, name string) dockercompat.Image { var res dockercompat.Image cmd := helpers.Command("image", "inspect", name) cmd.Run(&test.Expected{ - Output: expect.JSON([]dockercompat.Image{}, func(dc []dockercompat.Image, _ string, t tig.T) { + Output: expect.JSON([]dockercompat.Image{}, func(dc []dockercompat.Image, t tig.T) { assert.Equal(t, 1, len(dc), "Unexpectedly got multiple results") res = dc[0] }), @@ -113,13 +126,13 @@ func EnsureContainerStarted(helpers test.Helpers, con string) { helpers.Command("container", "inspect", con). Run(&test.Expected{ ExitCode: expect.ExitCodeNoCheck, - Output: func(stdout string, info string, t *testing.T) { + Output: func(stdout string, t tig.T) { var dc []dockercompat.Container err := json.Unmarshal([]byte(stdout), &dc) if err != nil || len(dc) == 0 { return } - assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n"+info) + assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n") started = dc[0].State.Running }, }) @@ -133,7 +146,51 @@ func EnsureContainerStarted(helpers test.Helpers, con string) { helpers.T().Log(ins) helpers.T().Log(lgs) helpers.T().Log(ps) - helpers.T().Fatalf("container %s still not running after %d retries", con, maxRetry) + helpers.T().Log(fmt.Sprintf("container %s still not running after %d retries", con, maxRetry)) + helpers.T().FailNow() + } +} + +func EnsureContainerExited(helpers test.Helpers, con string, exitCode int) { + helpers.T().Helper() + exited := false + for i := 0; i < maxRetry && !exited; i++ { + helpers.Command("container", "inspect", con). + Run(&test.Expected{ + ExitCode: expect.ExitCodeNoCheck, + Output: func(stdout string, t tig.T) { + var dc []dockercompat.Container + err := json.Unmarshal([]byte(stdout), &dc) + if err != nil || len(dc) == 0 || (len(dc) > 0 && dc[0].State == nil) { + return + } + assert.Equal(t, len(dc), 1, "Unexpectedly got multiple results\n") + state := dc[0].State + if state.Running { + return + } + if state.Status != "exited" && state.Status != "dead" { + return + } + // Use a negative exitCode to ignore the exit code and only verify exited/dead state. + if exitCode >= 0 && state.ExitCode != exitCode { + return + } + exited = true + }, + }) + time.Sleep(sleep) + } + + if !exited { + ins := helpers.Capture("container", "inspect", con) + lgs := helpers.Capture("logs", con) + ps := helpers.Capture("ps", "-a") + helpers.T().Log(ins) + helpers.T().Log(lgs) + helpers.T().Log(ps) + helpers.T().Log(fmt.Sprintf("container %s still not exited after %d retries", con, maxRetry)) + helpers.T().FailNow() } } diff --git a/pkg/testutil/nerdtest/utilities_linux.go b/pkg/testutil/nerdtest/utilities_linux.go index bc652564f83..0c996d77ce9 100644 --- a/pkg/testutil/nerdtest/utilities_linux.go +++ b/pkg/testutil/nerdtest/utilities_linux.go @@ -17,6 +17,9 @@ package nerdtest import ( + "net" + "net/http" + "net/http/httptest" "os" "strconv" "strings" @@ -26,6 +29,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/test" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" ) const SignalCaught = "received" @@ -68,8 +72,32 @@ func RunSigProxyContainer(signal os.Signal, exitOnSignal bool, args []string, da if strings.Contains(out, ready) { break } - time.Sleep(100 * time.Millisecond) + time.Sleep(1 * time.Second) } return cmd } + +// StartHTTPServer starts an HTTP server bound to 0.0.0.0 and returns a URL reachable +// from processes that cannot access 127.0.0.1 due to namespace isolation. +// It also returns a cleanup function that stops the server. +func StartHTTPServer(handler http.Handler) (url string, stop func(), err error) { + l, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + return "", nil, err + } + srv := &httptest.Server{Config: &http.Server{Handler: handler}} + srv.Listener = l + srv.Start() + hostIP, herr := nettestutil.NonLoopbackIPv4() + if herr != nil { + srv.Close() + return "", nil, herr + } + _, port, perr := net.SplitHostPort(l.Addr().String()) + if perr != nil { + srv.Close() + return "", nil, perr + } + return "http://" + hostIP.String() + ":" + port, func() { srv.Close() }, nil +} diff --git a/pkg/testutil/nettestutil/nettestutil.go b/pkg/testutil/nettestutil/nettestutil.go index 4937b8acc21..3613a59b22c 100644 --- a/pkg/testutil/nettestutil/nettestutil.go +++ b/pkg/testutil/nettestutil/nettestutil.go @@ -48,7 +48,7 @@ func HTTPGet(urlStr string, attempts int, insecure bool) (*http.Response, error) if err == nil { return resp, nil } - time.Sleep(100 * time.Millisecond) + time.Sleep(1 * time.Second) } return nil, fmt.Errorf("error after %d attempts: %w", attempts, err) } diff --git a/pkg/testutil/testregistry/testregistry_linux.go b/pkg/testutil/testregistry/testregistry_linux.go index d6610f9046a..fec05871fd9 100644 --- a/pkg/testutil/testregistry/testregistry_linux.go +++ b/pkg/testutil/testregistry/testregistry_linux.go @@ -26,6 +26,7 @@ import ( "golang.org/x/crypto/bcrypt" "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" @@ -56,13 +57,6 @@ type TokenAuthServer struct { func EnsureImages(base *testutil.Base) { registryImage := platform.RegistryImageStable - up := os.Getenv("DISTRIBUTION_VERSION") - if up != "" { - if up[0:1] != "v" { - up = "v" + up - } - registryImage = platform.RegistryImageNext + up - } base.Cmd("pull", "--quiet", registryImage).AssertOK() base.Cmd("pull", "--quiet", platform.DockerAuthImage).AssertOK() base.Cmd("pull", "--quiet", platform.KuboImage).AssertOK() @@ -161,7 +155,7 @@ acl: return cmd.Error } joined := net.JoinHostPort(hostIP.String(), strconv.Itoa(port)) - _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/auth", scheme, joined), 30, true) + _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/auth", scheme, joined), 5, true) return err }() @@ -234,7 +228,7 @@ func (ba *BasicAuth) Params(base *testutil.Base) []string { encryptedPass, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) tmpDir, _ := os.MkdirTemp(base.T.TempDir(), "htpasswd") ba.HtFile = filepath.Join(tmpDir, "htpasswd") - _ = os.WriteFile(ba.HtFile, []byte(fmt.Sprintf(`%s:%s`, ba.Username, string(encryptedPass[:]))), 0600) + _ = filesystem.WriteFile(ba.HtFile, []byte(fmt.Sprintf(`%s:%s`, ba.Username, string(encryptedPass[:]))), 0600) } ret := []string{ "--env", "REGISTRY_AUTH=htpasswd", @@ -284,14 +278,6 @@ func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundC args = append(args, auth.Params(base)...) registryImage := testutil.RegistryImageStable - - up := os.Getenv("DISTRIBUTION_VERSION") - if up != "" { - if up[0:1] != "v" { - up = "v" + up - } - registryImage = testutil.RegistryImageNext + up - } args = append(args, registryImage) cleanup := func(err error) { @@ -354,7 +340,7 @@ func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundC return "", cmd.Error } - if _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s:%s/v2", scheme, hostIP.String(), strconv.Itoa(port)), 30, true); err != nil { + if _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s:%s/v2", scheme, hostIP.String(), strconv.Itoa(port)), 5, true); err != nil { return "", err } diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index dd57f2821e7..f6bf39dda7a 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -40,11 +40,10 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" - "github.com/containerd/nerdctl/v2/pkg/imgutil" "github.com/containerd/nerdctl/v2/pkg/infoutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/v2/pkg/lockutil" + "github.com/containerd/nerdctl/v2/pkg/internal/filesystem" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) @@ -292,7 +291,7 @@ func (b *Base) ContainerdAddress() string { xdr = fmt.Sprintf("/run/user/%d", os.Geteuid()) } pidFile := filepath.Join(xdr, "containerd-rootless", "child_pid") - pidB, err := os.ReadFile(pidFile) + pidB, err := filesystem.ReadFile(pidFile) if err != nil { b.T.Fatal(err) } @@ -524,17 +523,17 @@ func M(m *testing.M) { os.Chmod(filepath.Dir(testLockFile), 0o777) // Acquire lock - lock, err := lockutil.Lock(filepath.Dir(testLockFile)) + lock, err := filesystem.Lock(filepath.Dir(testLockFile)) if err != nil { log.L.WithError(err).Errorf("failed acquiring testing lock %q", filepath.Dir(testLockFile)) return 1 } // Release... - defer lockutil.Unlock(lock) + defer filesystem.Unlock(lock) // Create marker file - err = os.WriteFile(testLockFile, []byte("prevent testing from running in parallel for subpackages integration tests"), 0o666) + err = filesystem.WriteFile(testLockFile, []byte("prevent testing from running in parallel for subpackages integration tests"), 0o666) if err != nil { log.L.WithError(err).Errorf("failed writing lock file %q", testLockFile) return 1 @@ -749,13 +748,6 @@ func Identifier(t testing.TB) string { return s } -// ImageRepo returns the image repo that can be used to, e.g, validate output -// from `nerdctl images`. -func ImageRepo(s string) string { - repo, _ := imgutil.ParseRepoTag(s) - return repo -} - // RegisterBuildCacheCleanup adds a 'builder prune --all --force' cleanup function // to run on test teardown. func RegisterBuildCacheCleanup(t *testing.T) { @@ -763,8 +755,3 @@ func RegisterBuildCacheCleanup(t *testing.T) { NewBase(t).Cmd("builder", "prune", "--all", "--force").Run() }) } - -func mirrorOf(s string) string { - // plain mirror, NOT stargz-converted images - return fmt.Sprintf("ghcr.io/stargz-containers/%s-org", s) -} diff --git a/pkg/testutil/testutil_darwin.go b/pkg/testutil/testutil_darwin.go index 07990bfef57..bc108cc525a 100644 --- a/pkg/testutil/testutil_darwin.go +++ b/pkg/testutil/testutil_darwin.go @@ -28,8 +28,8 @@ const ( ) var ( - BusyboxImage = "ghcr.io/containerd/busybox:1.36" - AlpineImage = mirrorOf("alpine:3.13") - NginxAlpineImage = mirrorOf("nginx:1.19-alpine") - GolangImage = mirrorOf("golang:1.18") + BusyboxImage = "there-is-no-test-on-darwin" + AlpineImage = "there-is-no-test-on-darwin" + NginxAlpineImage = "there-is-no-test-on-darwin" + GolangImage = "there-is-no-test-on-darwin" ) diff --git a/pkg/testutil/testutil_freebsd.go b/pkg/testutil/testutil_freebsd.go index 9761008585f..0eb44c10614 100644 --- a/pkg/testutil/testutil_freebsd.go +++ b/pkg/testutil/testutil_freebsd.go @@ -28,8 +28,8 @@ const ( ) var ( - BusyboxImage = "ghcr.io/containerd/busybox:1.36" - AlpineImage = mirrorOf("alpine:3.13") - NginxAlpineImage = mirrorOf("nginx:1.19-alpine") - GolangImage = mirrorOf("golang:1.18") + BusyboxImage = "there-is-no-such-test-on-freebsd" + AlpineImage = "there-is-no-such-test-on-freebsd" + NginxAlpineImage = "there-is-no-such-test-on-freebsd" + GolangImage = "there-is-no-such-test-on-freebsd" ) diff --git a/pkg/testutil/testutil_linux.go b/pkg/testutil/testutil_linux.go index f7ab0688d42..3f7f2c85337 100644 --- a/pkg/testutil/testutil_linux.go +++ b/pkg/testutil/testutil_linux.go @@ -17,38 +17,39 @@ package testutil var ( - BusyboxImage = "ghcr.io/containerd/busybox:1.36" - AlpineImage = mirrorOf("alpine:3.13") - NginxAlpineImage = mirrorOf("nginx:1.19-alpine") - NginxAlpineIndexHTMLSnippet = "Welcome to nginx!" - RegistryImageStable = mirrorOf("registry:2") - RegistryImageNext = "ghcr.io/distribution/distribution:" - WordpressImage = mirrorOf("wordpress:5.7") - WordpressIndexHTMLSnippet = "WordPress › Installation" - MariaDBImage = mirrorOf("mariadb:10.5") - DockerAuthImage = mirrorOf("cesanta/docker_auth:1.7") - FluentdImage = "fluent/fluentd:v1.17.0-debian-1.0" - KuboImage = mirrorOf("ipfs/kubo:v0.16.0") - SystemdImage = "ghcr.io/containerd/stargz-snapshotter:0.15.1-kind" - GolangImage = mirrorOf("golang:1.18") - - // Source: https://gist.github.com/cpuguy83/fcf3041e5d8fb1bb5c340915aabeebe0 - NonDistBlobImage = "ghcr.io/cpuguy83/non-dist-blob:latest" - // Foreign layer digest - NonDistBlobDigest = "sha256:be691b1535726014cdf3b715ff39361b19e121ca34498a9ceea61ad776b9c215" + AlpineImage = GetTestImage("alpine") + BusyboxImage = GetTestImage("busybox") + DockerAuthImage = GetTestImage("docker_auth") + FluentdImage = GetTestImage("fluentd") + GolangImage = GetTestImage("golang") + KuboImage = GetTestImage("kubo") + MariaDBImage = GetTestImage("mariadb") + NginxAlpineImage = GetTestImage("nginx") + RegistryImageStable = GetTestImage("registry") + SystemdImage = GetTestImage("stargz") + WordpressImage = GetTestImage("wordpress") CommonImage = AlpineImage + FedoraESGZImage = GetTestImage("fedora_esgz") // eStargz + FfmpegSociImage = GetTestImage("ffmpeg_soci") // SOCI + UbuntuImage = GetTestImage("ubuntu") // Large enough for testing soci index creation + CoreDNSImage = GetTestImage("coredns") +) + +const ( // This error string is expected when attempting to connect to a TCP socket // for a service which actively refuses the connection. // (e.g. attempting to connect using http to an https endpoint). // It should be "connection refused" as per the TCP RFC. // https://www.rfc-editor.org/rfc/rfc793 ExpectedConnectionRefusedError = "connection refused" -) -const ( - FedoraESGZImage = "ghcr.io/stargz-containers/fedora:30-esgz" // eStargz - FfmpegSociImage = "public.ecr.aws/soci-workshop-examples/ffmpeg:latest" // SOCI - UbuntuImage = "public.ecr.aws/docker/library/ubuntu:23.10" // Large enough for testing soci index creation + NginxAlpineIndexHTMLSnippet = "Welcome to nginx!" + WordpressIndexHTMLSnippet = "WordPress › Installation" + + // Source: https://gist.github.com/cpuguy83/fcf3041e5d8fb1bb5c340915aabeebe0 + NonDistBlobImage = "ghcr.io/cpuguy83/non-dist-blob:latest@sha256:8859ffb0bb604463fe19f1e606ceda9f4f8f42e095bf78c42458cf6da7b5c7e7" + // Foreign layer digest + NonDistBlobDigest = "sha256:be691b1535726014cdf3b715ff39361b19e121ca34498a9ceea61ad776b9c215" ) diff --git a/pkg/testutil/testutil_windows.go b/pkg/testutil/testutil_windows.go index d1b830da6bf..1d3c46e4150 100644 --- a/pkg/testutil/testutil_windows.go +++ b/pkg/testutil/testutil_windows.go @@ -53,8 +53,8 @@ const ( ) var ( - GolangImage = mirrorOf("fixme-test-using-this-image-is-disabled-on-windows") - AlpineImage = mirrorOf("fixme-test-using-this-image-is-disabled-on-windows") + GolangImage = "fixme-test-using-this-image-is-disabled-on-windows" + AlpineImage = "fixme-test-using-this-image-is-disabled-on-windows" hypervContainer bool hypervSupported bool diff --git a/pkg/transferutil/progress.go b/pkg/transferutil/progress.go new file mode 100644 index 00000000000..15baf5d508d --- /dev/null +++ b/pkg/transferutil/progress.go @@ -0,0 +1,231 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package transferutil + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/transfer" + "github.com/containerd/containerd/v2/pkg/progress" +) + +// From https://github.com/containerd/containerd/blob/v2.2.0-rc.0/cmd/ctr/commands/image/pull.go#L240-L473 +type progressNode struct { + transfer.Progress + children []*progressNode + root bool +} + +func (n *progressNode) mainDesc() *ocispec.Descriptor { + if n.Desc != nil { + return n.Desc + } + for _, c := range n.children { + if desc := c.mainDesc(); desc != nil { + return desc + } + } + return nil +} + +// ProgressHandler returns a progress callback and a cleanup function to render transfer progress. +// This implementation is based on containerd's ctr command progress handler. +func ProgressHandler(ctx context.Context, out io.Writer) (transfer.ProgressFunc, func()) { + ctx, cancel := context.WithCancel(ctx) + var ( + fw = progress.NewWriter(out) + start = time.Now() + statuses = map[string]*progressNode{} + roots = []*progressNode{} + pc = make(chan transfer.Progress, 5) + status string + closeC = make(chan struct{}) + ) + + progressFn := func(p transfer.Progress) { + select { + case pc <- p: + case <-ctx.Done(): + } + } + + done := func() { + cancel() + <-closeC + } + + go func() { + defer close(closeC) + for { + select { + case p := <-pc: + if p.Name == "" { + status = p.Event + continue + } + if node, ok := statuses[p.Name]; !ok { + node = &progressNode{ + Progress: p, + root: true, + } + if len(p.Parents) == 0 { + roots = append(roots, node) + } else { + var parents []string + for _, parent := range p.Parents { + pStatus, ok := statuses[parent] + if ok { + parents = append(parents, parent) + pStatus.children = append(pStatus.children, node) + node.root = false + } + } + node.Progress.Parents = parents + if node.root { + roots = append(roots, node) + } + } + statuses[p.Name] = node + } else { + if len(node.Progress.Parents) != len(p.Parents) { + var parents []string + var removeRoot bool + for _, parent := range p.Parents { + pStatus, ok := statuses[parent] + if ok { + parents = append(parents, parent) + var found bool + for _, child := range pStatus.children { + if child.Progress.Name == p.Name { + found = true + break + } + } + if !found { + pStatus.children = append(pStatus.children, node) + } + if node.root { + removeRoot = true + } + node.root = false + } + } + p.Parents = parents + // Check if needs to remove from root + if removeRoot { + for i := range roots { + if roots[i] == node { + roots = append(roots[:i], roots[i+1:]...) + break + } + } + } + } + node.Progress = p + } + + displayHierarchy(fw, status, roots, start) + fw.Flush() + + case <-ctx.Done(): + return + } + } + }() + + return progressFn, done +} + +func displayHierarchy(w io.Writer, status string, roots []*progressNode, start time.Time) { + total := displayNode(w, "", roots) + for _, r := range roots { + if desc := r.mainDesc(); desc != nil { + fmt.Fprintf(w, "%s %s\n", desc.MediaType, desc.Digest) + } + } + // Print the Status line + fmt.Fprintf(w, "%s\telapsed: %-4.1fs\ttotal: %7.6v\t(%v)\t\n", + status, + time.Since(start).Seconds(), + progress.Bytes(total), + progress.NewBytesPerSecond(total, time.Since(start))) +} + +func displayNode(w io.Writer, prefix string, nodes []*progressNode) int64 { + var total int64 + for i, node := range nodes { + status := node.Progress + total += status.Progress + pf, cpf := prefixes(i, len(nodes)) + if node.root { + pf, cpf = "", "" + } + + name := prefix + pf + shortenName(status.Name) + + switch status.Event { + case "downloading", "uploading", "extracting": + var bar progress.Bar + if status.Total > 0.0 { + bar = progress.Bar(float64(status.Progress) / float64(status.Total)) + } + fmt.Fprintf(w, "%-40.40s\t%-11s\t%40r\t%8.8s/%s\t\n", + name, + status.Event, + bar, + progress.Bytes(status.Progress), progress.Bytes(status.Total)) + case "resolving", "waiting": + bar := progress.Bar(0.0) + fmt.Fprintf(w, "%-40.40s\t%-11s\t%40r\t\n", + name, + status.Event, + bar) + case "complete", "extracted": + bar := progress.Bar(1.0) + fmt.Fprintf(w, "%-40.40s\t%-11s\t%40r\t\n", + name, + status.Event, + bar) + default: + fmt.Fprintf(w, "%-40.40s\t%s\t\n", + name, + status.Event) + } + total += displayNode(w, prefix+cpf, node.children) + } + return total +} + +func prefixes(index, length int) (string, string) { + if index+1 == length { + return "└──", " " + } + return "├──", "│ " +} + +func shortenName(name string) string { + if strings.HasPrefix(name, "sha256:") && len(name) == 71 { + return "(" + name[7:19] + ")" + } + return name +}