diff --git a/.cirrus.yml b/.cirrus.yml deleted file mode 100644 index b35984c3058..00000000000 --- a/.cirrus.yml +++ /dev/null @@ -1,41 +0,0 @@ -freebsd_task: - name: FreeBSD - timeout_in: 20m - compute_engine_instance: - image_project: freebsd-org-cloud-dev - image: family/freebsd-13-0 - platform: freebsd - cpu: 2 - memory: 4G - env: - NERDCTL_RUN_ARGS: --net none dougrabson/freebsd-minimal:13.1 echo "Nerdctl is up and running." - install_script: - - pkg install -y go containerd runj - test_script: - - daemon -o containerd.out containerd - - go test -v ./pkg/... - - cd cmd/nerdctl - - sudo go run . run $NERDCTL_RUN_ARGS | grep running -# TODO: run `go test -v ./cmd/...` - -windows_task: - name: "Windows" - timeout_in: 20m - compute_engine_instance: - image_project: cirrus-images - image: family/windows-docker-builder - platform: windows - cpu: 2 - memory: 4G - matrix: - - name: "Windows/containerd-1.7" - env: - ctrdVersion: 1.7.1 - env: - CGO_ENABLED: 0 - build_script: - - mkdir "C:\Windows\system32\config\systemprofile\AppData\Local\Temp\" - - powershell hack/configure-windows-ci.ps1 - - refreshenv - - go install .\cmd\nerdctl\ - - go test -v ./cmd/... diff --git a/.dockerignore b/.dockerignore index 33c9733d860..646cd932d8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,7 @@ _output # golangci-lint -build +/build # vagrant /.vagrant diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0fedec74806..0c21fa19f87 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,26 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + groups: + golang-x: + patterns: + - "golang.org/x/*" + moby-sys: + patterns: + - "github.com/moby/sys/*" + docker: + patterns: + - "github.com/docker/docker" + - "github.com/docker/cli" + containerd: + patterns: + - "github.com/containerd/containerd" + - "github.com/containerd/containerd/api" + stargz: + patterns: + - "github.com/containerd/stargz-snapshotter" + - "github.com/containerd/stargz-snapshotter/estargz" + - "github.com/containerd/stargz-snapshotter/ipfs" # Dependencies listed in .github/workflows/*.yml - package-ecosystem: "github-actions" diff --git a/.github/workflows/ghcr-image-build-and-publish.yml b/.github/workflows/ghcr-image-build-and-publish.yml index f8d2a0ab68b..8cc17c5287d 100644 --- a/.github/workflows/ghcr-image-build-and-publish.yml +++ b/.github/workflows/ghcr-image-build-and-publish.yml @@ -1,4 +1,4 @@ -name: Container Image Build +name: image # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by @@ -12,6 +12,8 @@ on: tags: ['v*.*.*'] pull_request: branches: [main] + paths-ignore: + - '**.md' env: # Use docker.io for Docker Hub if empty @@ -19,30 +21,29 @@ env: # github.repository as / IMAGE_NAME: ${{ github.repository }} - jobs: build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: read packages: write steps: - name: Checkout repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3.3.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.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@v2.2.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -52,14 +53,14 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v4.6.0 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 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@v4.1.1 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..b98ddafac52 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,76 @@ +name: lint + +on: + push: + branches: + - main + - 'release/**' + pull_request: + +env: + GO_VERSION: 1.23.x + +jobs: + go: + timeout-minutes: 5 + name: "go | ${{ matrix.goos }} | ${{ matrix.canary }}" + runs-on: "${{ matrix.os }}" + defaults: + run: + shell: bash + strategy: + matrix: + include: + - os: ubuntu-24.04 + goos: linux + - os: ubuntu-24.04 + goos: freebsd + # FIXME: this is currently failing in a non-sensical way, so, running on linux instead... + # - os: windows-2022 + - os: ubuntu-24.04 + goos: windows + - os: ubuntu-24.04 + goos: linux + # This allows the canary script to select any upcoming golang alpha/beta/RC + canary: go-canary + env: + GOOS: "${{ matrix.goos }}" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - name: Set GO env + run: | + # If canary is specified, get the latest available golang pre-release instead of the major version + if [ "$canary" != "" ]; then + . ./hack/build-integration-canary.sh + canary::golang::latest + fi + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + - name: golangci-lint + uses: golangci/golangci-lint-action@ec5d18412c0aeab7936cb16880d708ba2a64e1ae # v6.2.0 + with: + args: --verbose + other: + timeout-minutes: 5 + name: yaml | shell | imports order + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + - name: yaml + run: make lint-yaml + - name: shell + run: make lint-shell + - name: go imports ordering + run: | + go install -v github.com/incu6us/goimports-reviser/v3@latest + make lint-imports diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml new file mode 100644 index 00000000000..bdb34f52d5f --- /dev/null +++ b/.github/workflows/project.yml @@ -0,0 +1,31 @@ +name: project + +on: + push: + branches: + - main + - 'release/**' + pull_request: + +jobs: + project: + name: checks + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + path: src/github.com/containerd/nerdctl + fetch-depth: 100 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: src/github.com/containerd/nerdctl + - uses: containerd/project-checks@434a07157608eeaa1d5c8d4dd506154204cd9401 # v1.1.0 + with: + working-directory: src/github.com/containerd/nerdctl + repo-access-token: ${{ secrets.GITHUB_TOKEN }} + - run: ./hack/verify-no-patent.sh + working-directory: src/github.com/containerd/nerdctl + - run: ./hack/verify-pkg-isolation.sh + working-directory: src/github.com/containerd/nerdctl diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52dd9a4f91a..b3a17aa0813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,17 +5,15 @@ on: tags: - 'v*' - 'test-action-release-*' -env: - GO111MODULE: on jobs: release: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 timeout-minutes: 40 steps: - - uses: actions/checkout@v3.5.3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 with: - go-version: 1.20.x + go-version: 1.23.x - name: "Compile binaries" run: make artifacts - name: "SHA256SUMS" @@ -26,11 +24,8 @@ jobs: run: (cd _output; sha256sum SHA256SUMS) - name: "Prepare the release note" run: | - tag="${GITHUB_REF##*/}" shasha=$(sha256sum _output/SHA256SUMS | awk '{print $1}') cat <<-EOF | tee /tmp/release-note.txt - ${tag} - $(hack/generate-release-note.sh) - - - The binaries were built automatically on GitHub Actions. @@ -45,6 +40,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag="${GITHUB_REF##*/}" - asset_flags=() - for f in _output/*; do asset_flags+=("-a" "$f"); done - hub release create "${asset_flags[@]}" -F /tmp/release-note.txt --draft "${tag}" + gh release create -F /tmp/release-note.txt --draft --title "${tag}" "${tag}" _output/* diff --git a/.github/workflows/test-canary.yml b/.github/workflows/test-canary.yml new file mode 100644 index 00000000000..c178b907df4 --- /dev/null +++ b/.github/workflows/test-canary.yml @@ -0,0 +1,98 @@ +# This pipeline purpose is solely meant to run a subset of our test suites against upcoming or unreleased dependencies versions +name: canary + +on: + push: + branches: + - main + - 'release/**' + pull_request: + paths-ignore: + - '**.md' + +env: + UBUNTU_VERSION: "24.04" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + linux: + runs-on: "ubuntu-24.04" + timeout-minutes: 40 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - name: "Prepare integration test environment" + run: | + . ./hack/build-integration-canary.sh + canary::build::integration + - name: "Remove snap loopback devices (conflicts with our loopback devices in TestRunDevice)" + run: | + sudo systemctl disable --now snapd.service snapd.socket + sudo apt-get purge -y snapd + sudo losetup -Dv + sudo losetup -lv + - name: "Register QEMU (tonistiigi/binfmt)" + run: | + # `--install all` will only install emulation for architectures that cannot be natively executed + # 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 + - name: "Run unit tests" + run: go test -v ./pkg/... + - name: "Run integration tests" + run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=false + - name: "Run integration tests (flaky)" + run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=true + + windows: + timeout-minutes: 30 + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - name: Set GO env + run: | + # Get latest containerd + args=(curl --proto '=https' --tlsv1.2 -fsSL -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28") + [ "${GITHUB_TOKEN:-}" == "" ] && { + >&2 printf "GITHUB_TOKEN is not set - you might face rate limitations with the Github API\n" + } || args+=(-H "Authorization: Bearer $GITHUB_TOKEN") + ctd_v="$("${args[@]}" https://api.github.com/repos/containerd/containerd/tags | jq -rc .[0].name)" + echo "CONTAINERD_VERSION=${ctd_v:1}" >> "$GITHUB_ENV" + + . ./hack/build-integration-canary.sh + canary::golang::latest + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + - run: go install ./cmd/nerdctl + - run: go install -v gotest.tools/gotestsum@v1 + # This here is solely to get the cni install script, which has not been modified in 3+ years. + # There is little to no reason to update this to latest containerd + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: containerd/containerd + ref: "v1.7.25" + path: containerd + fetch-depth: 1 + - name: "Set up CNI" + working-directory: containerd + run: GOPATH=$(go env GOPATH) script/setup/install-cni-windows + # Windows setup script can only use released versions + - name: "Set up containerd" + env: + ctrdVersion: ${{ env.CONTAINERD_VERSION }} + run: powershell hack/configure-windows-ci.ps1 + - name: "Run integration tests" + run: ./hack/test-integration.sh -test.only-flaky=false + - name: "Run integration tests (flaky)" + run: ./hack/test-integration.sh -test.only-flaky=true diff --git a/.github/workflows/test-kube.yml b/.github/workflows/test-kube.yml new file mode 100644 index 00000000000..580a9a2181a --- /dev/null +++ b/.github/workflows/test-kube.yml @@ -0,0 +1,27 @@ +# This pipeline purpose is solely meant to run a subset of our test suites against a kubernetes cluster +name: kubernetes + +on: + push: + branches: + - main + - 'release/**' + pull_request: + paths-ignore: + - '**.md' + +jobs: + linux: + runs-on: "ubuntu-24.04" + timeout-minutes: 40 + env: + ROOTFUL: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - name: "Run Kubernetes integration tests" + # See https://github.com/containerd/nerdctl/blob/main/docs/testing/README.md#about-parallelization + run: | + ./hack/build-integration-kubernetes.sh + sudo ./_output/nerdctl exec nerdctl-test-control-plane bash -c -- 'export TMPDIR="$HOME"/tmp; mkdir -p "$TMPDIR"; cd /nerdctl-source; /usr/local/go/bin/go test -p 1 ./cmd/nerdctl/... -test.only-kubernetes' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb243e08431..8f634ee93d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,93 +6,195 @@ on: - main - 'release/**' pull_request: + paths-ignore: + - '**.md' env: - GO_VERSION: 1.20.x + GO_VERSION: 1.23.x + SHORT_TIMEOUT: 5 + LONG_TIMEOUT: 60 jobs: - project: - name: Project Checks - runs-on: ubuntu-22.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@v3.5.3 - with: - path: src/github.com/containerd/nerdctl - fetch-depth: 100 - - uses: actions/setup-go@v4 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: src/github.com/containerd/nerdctl - - uses: containerd/project-checks@v1.1.0 - with: - working-directory: src/github.com/containerd/nerdctl - repo-access-token: ${{ secrets.GITHUB_TOKEN }} - - run: ./hack/verify-no-patent.sh - working-directory: src/github.com/containerd/nerdctl - - run: ./hack/verify-pkg-isolation.sh - working-directory: src/github.com/containerd/nerdctl - - lint: - runs-on: ubuntu-22.04 - timeout-minutes: 20 + # This job builds the dependency target of the test docker image for all supported architectures and cache it in GHA + build-dependencies: + timeout-minutes: 15 + name: dependencies | ${{ matrix.containerd }} | ${{ matrix.arch }} + runs-on: "${{ matrix.runner }}" + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + containerd: v1.6.36 + arch: amd64 + - runner: ubuntu-24.04 + containerd: v1.7.25 + arch: amd64 + - runner: ubuntu-24.04 + containerd: v2.0.2 + arch: amd64 + - runner: arm64-8core-32gb + containerd: v2.0.2 + arch: arm64 + env: + CONTAINERD_VERSION: "${{ matrix.containerd }}" + ARCH: "${{ matrix.arch }}" steps: - - uses: actions/checkout@v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - - uses: actions/setup-go@v4 - with: - go-version: ${{ env.GO_VERSION }} - check-latest: true - cache: true - - name: golangci-lint - uses: golangci/golangci-lint-action@v3.6.0 - with: - version: v1.51.1 - args: --verbose - - name: yamllint-lint - run: yamllint . + - name: "Expose GitHub Runtime variables for gha" + uses: crazy-max/ghaction-github-runtime@b3a9207c0e1ef41f4cf215303c976869d0c2c1c4 # v3.0.0 + - name: "Build dependencies for the integration test environment image" + run: | + docker buildx create --name with-gha --use + docker buildx build \ + --output=type=docker \ + --cache-to type=gha,mode=max,scope=${ARCH}-${CONTAINERD_VERSION} \ + --cache-from type=gha,scope=${ARCH}-${CONTAINERD_VERSION} \ + --target build-dependencies --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} . test-unit: - runs-on: ubuntu-22.04 - timeout-minutes: 20 + # FIXME: + # Supposed to work: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#example-returning-a-json-data-type + # Apparently does not + # timeout-minutes: ${{ fromJSON(env.SHORT_TIMEOUT) }} + timeout-minutes: 10 + name: unit | ${{ matrix.goos }} + runs-on: "${{ matrix.os }}" + defaults: + run: + shell: bash + strategy: + matrix: + include: + - os: windows-2022 + goos: windows + - os: ubuntu-24.04 + goos: linux steps: - - uses: actions/checkout@v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 with: go-version: ${{ env.GO_VERSION }} check-latest: true - cache: true + - if: ${{ matrix.goos=='windows' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: containerd/containerd + ref: v1.7.25 + path: containerd + fetch-depth: 1 + - if: ${{ matrix.goos=='windows' }} + name: "Set up CNI" + working-directory: containerd + run: GOPATH=$(go env GOPATH) script/setup/install-cni-windows - name: "Run unit tests" - run: go test -v ./pkg/... + run: make test-unit test-integration: - runs-on: "ubuntu-${{ matrix.ubuntu }}" - timeout-minutes: 40 + needs: build-dependencies + timeout-minutes: 30 + name: rootful | ${{ matrix.containerd }} | ${{ matrix.runner }} + runs-on: "${{ matrix.runner }}" strategy: fail-fast: false matrix: - # ubuntu-20.04: cgroup v1, ubuntu-22.04: cgroup v2 + # ubuntu-20.04: cgroup v1, ubuntu-22.04 and later: cgroup v2 include: - ubuntu: 20.04 - containerd: v1.6.21 - - ubuntu: 20.04 - containerd: v1.7.1 - - ubuntu: 22.04 - containerd: v1.7.1 + containerd: v1.6.36 + runner: "ubuntu-20.04" + arch: amd64 - ubuntu: 22.04 - containerd: main + containerd: v1.7.25 + runner: "ubuntu-22.04" + arch: amd64 + - ubuntu: 24.04 + containerd: v2.0.2 + runner: "ubuntu-24.04" + arch: amd64 + - ubuntu: 24.04 + containerd: v2.0.2 + runner: arm64-8core-32gb + arch: arm64 env: + CONTAINERD_VERSION: "${{ matrix.containerd }}" + ARCH: "${{ matrix.arch }}" UBUNTU_VERSION: "${{ matrix.ubuntu }}" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - name: "Expose GitHub Runtime variables for gha" + uses: crazy-max/ghaction-github-runtime@b3a9207c0e1ef41f4cf215303c976869d0c2c1c4 # v3.0.0 + - name: "Prepare integration test environment" + run: | + docker buildx create --name with-gha --use + docker buildx build \ + --output=type=docker \ + --cache-from type=gha,scope=${ARCH}-${CONTAINERD_VERSION} \ + -t test-integration --target test-integration --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} . + - name: "Remove snap loopback devices (conflicts with our loopback devices in TestRunDevice)" + run: | + sudo systemctl disable --now snapd.service snapd.socket + sudo apt-get purge -y snapd + sudo losetup -Dv + sudo losetup -lv + - name: "Register QEMU (tonistiigi/binfmt)" + run: | + # `--install all` will only install emulation for architectures that cannot be natively executed + # 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 + - name: "Run integration tests" + run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=false + - name: "Run integration tests (flaky)" + run: docker run -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-flaky=true + + test-integration-ipv6: + needs: build-dependencies + timeout-minutes: 15 + name: ipv6 | ${{ matrix.containerd }} | ${{ matrix.ubuntu }} + runs-on: "ubuntu-${{ matrix.ubuntu }}" + strategy: + fail-fast: false + matrix: + include: + - ubuntu: 24.04 + containerd: v2.0.2 + arch: amd64 + env: CONTAINERD_VERSION: "${{ matrix.containerd }}" + ARCH: "${{ matrix.arch }}" + UBUNTU_VERSION: "${{ matrix.ubuntu }}" steps: - - uses: actions/checkout@v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 + - name: Enable ipv4 and ipv6 forwarding + run: | + sudo sysctl -w net.ipv6.conf.all.forwarding=1 + sudo sysctl -w net.ipv4.ip_forward=1 + - name: "Expose GitHub Runtime variables for gha" + uses: crazy-max/ghaction-github-runtime@b3a9207c0e1ef41f4cf215303c976869d0c2c1c4 # v3.0.0 + - name: Enable IPv6 for Docker, and configure docker to use containerd for gha + run: | + 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 + sudo systemctl restart docker - name: "Prepare integration test environment" - run: DOCKER_BUILDKIT=1 docker build -t test-integration --target test-integration --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} . + run: | + docker buildx create --name with-gha --use + docker buildx build \ + --output=type=docker \ + --cache-from type=gha,scope=${ARCH}-${CONTAINERD_VERSION} \ + -t test-integration --target test-integration --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} . - name: "Remove snap loopback devices (conflicts with our loopback devices in TestRunDevice)" run: | sudo systemctl disable --now snapd.service snapd.socket @@ -100,91 +202,225 @@ jobs: sudo losetup -Dv sudo losetup -lv - name: "Register QEMU (tonistiigi/binfmt)" - run: docker run --privileged --rm tonistiigi/binfmt --install all + run: | + # `--install all` will only install emulation for architectures that cannot be natively executed + # 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 - name: "Run integration tests" - run: docker run -t --rm --privileged test-integration + # The nested IPv6 network inside docker and qemu is complex and needs a bunch of sysctl config. + # Therefore, it's hard to debug why the IPv6 tests fail in such an isolation layer. + # On the other side, using the host network is easier at configuration. + # Besides, each job is running on a different instance, which means using host network here + # is safe and has no side effects on others. + run: docker run --network host -t --rm --privileged test-integration ./hack/test-integration.sh -test.only-ipv6 test-integration-rootless: + needs: build-dependencies + timeout-minutes: 30 + name: "${{ matrix.target }} | ${{ matrix.containerd }} | ${{ matrix.rootlesskit }} | ${{ matrix.ubuntu }}" runs-on: "ubuntu-${{ matrix.ubuntu }}" - timeout-minutes: 60 strategy: fail-fast: false matrix: - # ubuntu-22.04: cgroup v1, ubuntu-22.04: cgroup v2 + # ubuntu-20.04: cgroup v1, ubuntu-22.04 and later: cgroup v2 include: - ubuntu: 20.04 - containerd: v1.6.21 - target: test-integration-rootless - - ubuntu: 20.04 - containerd: v1.7.1 - target: test-integration-rootless - - ubuntu: 22.04 - containerd: v1.7.1 - target: test-integration-rootless - - ubuntu: 22.04 - containerd: main - target: test-integration-rootless - - ubuntu: 20.04 - containerd: v1.6.21 - target: test-integration-rootless-port-slirp4netns - - ubuntu: 20.04 - containerd: v1.7.1 - target: test-integration-rootless-port-slirp4netns + containerd: v1.6.36 + rootlesskit: v1.1.1 # Deprecated + target: rootless + arch: amd64 - ubuntu: 22.04 - containerd: v1.7.1 - target: test-integration-rootless-port-slirp4netns - - ubuntu: 22.04 - containerd: main - target: test-integration-rootless-port-slirp4netns + containerd: v1.7.25 + rootlesskit: v2.3.2 + target: rootless + arch: amd64 + - ubuntu: 24.04 + containerd: v2.0.2 + rootlesskit: v2.3.2 + target: rootless + arch: amd64 + - ubuntu: 24.04 + containerd: v1.7.25 + rootlesskit: v2.3.2 + target: rootless-port-slirp4netns + arch: amd64 env: - UBUNTU_VERSION: "${{ matrix.ubuntu }}" CONTAINERD_VERSION: "${{ matrix.containerd }}" - TEST_TARGET: "${{ matrix.target }}" + ARCH: "${{ matrix.arch }}" + UBUNTU_VERSION: "${{ matrix.ubuntu }}" + ROOTLESSKIT_VERSION: "${{ matrix.rootlesskit }}" + TEST_TARGET: "test-integration-${{ matrix.target }}" steps: - - uses: actions/checkout@v3.5.3 + - name: "Set up AppArmor" + if: matrix.ubuntu == '24.04' + run: | + cat <, + include + + /usr/local/bin/rootlesskit flags=(unconfined) { + userns, + + # Site-specific additions and overrides. See local/README for details. + include if exists + } + EOT + sudo systemctl restart apparmor.service + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - name: "Register QEMU (tonistiigi/binfmt)" - run: docker run --privileged --rm tonistiigi/binfmt --install all + run: | + # `--install all` will only install emulation for architectures that cannot be natively executed + # 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 + - name: "Expose GitHub Runtime variables for gha" + uses: crazy-max/ghaction-github-runtime@b3a9207c0e1ef41f4cf215303c976869d0c2c1c4 # v3.0.0 - name: "Prepare (network driver=slirp4netns, port driver=builtin)" - run: DOCKER_BUILDKIT=1 docker build -t ${TEST_TARGET} --target ${TEST_TARGET} --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} . + run: | + docker buildx create --name with-gha --use + docker buildx build \ + --output=type=docker \ + --cache-from type=gha,scope=${ARCH}-${CONTAINERD_VERSION} \ + -t ${TEST_TARGET} --target ${TEST_TARGET} --build-arg UBUNTU_VERSION=${UBUNTU_VERSION} --build-arg CONTAINERD_VERSION=${CONTAINERD_VERSION} --build-arg ROOTLESSKIT_VERSION=${ROOTLESSKIT_VERSION} . + - name: "Disable BuildKit for RootlessKit v1 (workaround for issue #622)" + run: | + # https://github.com/containerd/nerdctl/issues/622 + WORKAROUND_ISSUE_622= + if echo "${ROOTLESSKIT_VERSION}" | grep -q v1; then + WORKAROUND_ISSUE_622=1 + fi + echo "WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622}" >> "$GITHUB_ENV" - name: "Test (network driver=slirp4netns, port driver=builtin)" - run: docker run -t --rm --privileged ${TEST_TARGET} + run: docker run -t --rm --privileged -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622} ${TEST_TARGET} /test-integration-rootless.sh ./hack/test-integration.sh -test.only-flaky=false + - name: "Test (network driver=slirp4netns, port driver=builtin) (flaky)" + run: docker run -t --rm --privileged -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622} ${TEST_TARGET} /test-integration-rootless.sh ./hack/test-integration.sh -test.only-flaky=true - cross: - runs-on: ubuntu-22.04 - timeout-minutes: 40 + build: + timeout-minutes: 5 + name: "build | ${{ matrix.go-version }}" + runs-on: ubuntu-24.04 strategy: matrix: - go-version: ["1.19.x", "1.20.x"] + go-version: ["1.22.x", "1.23.x"] steps: - - uses: actions/checkout@v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 with: go-version: ${{ matrix.go-version }} - cache: true check-latest: true - - name: "Cross" - run: GO_VERSION="$(echo ${{ matrix.go-version }} | sed -e s/.x//)" make artifacts + - name: "build" + run: GO_VERSION="$(echo ${{ matrix.go-version }} | sed -e s/.x//)" make binaries test-integration-docker-compatibility: - runs-on: ubuntu-22.04 timeout-minutes: 30 + name: docker + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 with: go-version: ${{ env.GO_VERSION }} - cache: true check-latest: true - name: "Register QEMU (tonistiigi/binfmt)" - run: docker run --privileged --rm tonistiigi/binfmt --install all + run: | + # `--install all` will only install emulation for architectures that cannot be natively executed + # 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 - name: "Prepare integration test environment" run: | sudo apt-get install -y expect + go install -v gotest.tools/gotestsum@v1 - name: "Ensure that the integration test suite is compatible with Docker" - run: go test -timeout 20m -v -exec sudo ./cmd/nerdctl/... -args -test.target=docker -test.kill-daemon + run: WITH_SUDO=true ./hack/test-integration.sh -test.target=docker + - name: "Ensure that the IPv6 integration test suite is compatible with Docker" + run: WITH_SUDO=true ./hack/test-integration.sh -test.target=docker -test.only-ipv6 + - name: "Ensure that the integration test suite is compatible with Docker (flaky only)" + run: WITH_SUDO=true ./hack/test-integration.sh -test.target=docker -test.only-flaky + + test-integration-windows: + timeout-minutes: 30 + name: windows + runs-on: windows-2022 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + - run: go install ./cmd/nerdctl + - run: go install -v gotest.tools/gotestsum@v1 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: containerd/containerd + ref: v1.7.25 + path: containerd + fetch-depth: 1 + - name: "Set up CNI" + working-directory: containerd + run: GOPATH=$(go env GOPATH) script/setup/install-cni-windows + - name: "Set up containerd" + env: + ctrdVersion: 1.7.25 + run: powershell hack/configure-windows-ci.ps1 + - name: "Run integration tests" + run: ./hack/test-integration.sh -test.only-flaky=false + - name: "Run integration tests (flaky)" + run: ./hack/test-integration.sh -test.only-flaky=true + + test-integration-freebsd: + timeout-minutes: 30 + name: FreeBSD + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: /root/.vagrant.d + key: vagrant-${{ matrix.box }} + - name: Set up vagrant + run: | + # 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 + curl -fsSL https://apt.releases.hashicorp.com/gpg | 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 + sudo apt-get install -y libvirt-daemon libvirt-daemon-system vagrant ovmf + # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/1725#issuecomment-1454058646 + sudo cp /usr/share/OVMF/OVMF_VARS_4M.fd /var/lib/libvirt/qemu/nvram/ + sudo systemctl enable --now libvirtd + sudo apt-get build-dep -y ruby-libvirt + sudo apt-get install -y --no-install-recommends libxslt-dev libxml2-dev libvirt-dev ruby-bundler ruby-dev zlib1g-dev + sudo vagrant plugin install vagrant-libvirt + - name: Boot VM + run: | + ln -sf Vagrantfile.freebsd Vagrantfile + sudo vagrant up --no-tty + - name: test-unit + run: sudo vagrant up --provision-with=test-unit + - name: test-integration + run: sudo vagrant up --provision-with=test-integration diff --git a/.gitignore b/.gitignore index 646cd932d8f..9381e921112 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ _output # vagrant /.vagrant +Vagrantfile diff --git a/.golangci.yml b/.golangci.yml index 0d2fec29133..f7dbbab93e0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,11 +1,11 @@ --- run: concurrency: 6 - deadline: 5m + timeout: 5m linters: disable-all: true enable: - - depguard + # - depguard - gofmt - goimports - govet @@ -134,3 +134,9 @@ linters-settings: - typeUnparen - unnamedResult - unnecessaryBlock + +issues: + exclude-rules: + - linters: + - revive + text: "unused-parameter" diff --git a/Dockerfile b/Dockerfile index 074d500360a..76866106b51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,51 +18,58 @@ # TODO: verify commit hash # Basic deps -ARG CONTAINERD_VERSION=v1.7.1 -ARG RUNC_VERSION=v1.1.7 -ARG CNI_PLUGINS_VERSION=v1.3.0 +ARG CONTAINERD_VERSION=v2.0.2 +ARG RUNC_VERSION=v1.2.4 +ARG CNI_PLUGINS_VERSION=v1.6.2 # Extra deps: Build -ARG BUILDKIT_VERSION=v0.11.6 +ARG BUILDKIT_VERSION=v0.19.0 # Extra deps: Lazy-pulling -ARG STARGZ_SNAPSHOTTER_VERSION=v0.14.3 +ARG STARGZ_SNAPSHOTTER_VERSION=v0.16.3 # Extra deps: Encryption -ARG IMGCRYPT_VERSION=v1.1.7 +ARG IMGCRYPT_VERSION=v2.0.0 # Extra deps: Rootless -ARG ROOTLESSKIT_VERSION=v1.1.0 -ARG SLIRP4NETNS_VERSION=v1.2.0 +ARG ROOTLESSKIT_VERSION=v2.3.2 +ARG SLIRP4NETNS_VERSION=v1.3.1 # Extra deps: bypass4netns -ARG BYPASS4NETNS_VERSION=v0.3.0 +ARG BYPASS4NETNS_VERSION=v0.4.2 # Extra deps: FUSE-OverlayFS -ARG FUSE_OVERLAYFS_VERSION=v1.12 -ARG CONTAINERD_FUSE_OVERLAYFS_VERSION=v1.0.6 -# Extra deps: IPFS -ARG KUBO_VERSION=v0.20.0 +ARG FUSE_OVERLAYFS_VERSION=v1.14 +ARG CONTAINERD_FUSE_OVERLAYFS_VERSION=v2.1.1 # Extra deps: Init ARG TINI_VERSION=v0.19.0 # Extra deps: Debug ARG BUILDG_VERSION=v0.4.1 # Test deps -ARG GO_VERSION=1.20 -ARG UBUNTU_VERSION=22.04 +ARG GO_VERSION=1.23 +ARG UBUNTU_VERSION=24.04 ARG CONTAINERIZED_SYSTEMD_VERSION=v0.1.1 -ARG GOTESTSUM_VERSION=v1.10.0 -ARG NYDUS_VERSION=v2.2.1 +ARG GOTESTSUM_VERSION=v1.12.0 +ARG NYDUS_VERSION=v2.3.0 +ARG SOCI_SNAPSHOTTER_VERSION=0.8.0 +ARG KUBO_VERSION=v0.32.1 -FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.2.1 AS xx +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.6.1 AS xx -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bullseye AS build-base-debian +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bookworm AS build-base-debian COPY --from=xx / / ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ - apt-get install -y git pkg-config dpkg-dev +RUN apt-get update -qq && apt-get install -qq --no-install-recommends \ + git \ + dpkg-dev ARG TARGETARCH # libbtrfs: for containerd # libseccomp: for runc and bypass4netns -RUN xx-apt-get update && \ - xx-apt-get install -y binutils gcc libc6-dev libbtrfs-dev libseccomp-dev +RUN xx-apt-get update -qq && xx-apt-get install -qq --no-install-recommends \ + binutils \ + gcc \ + libc6-dev \ + libbtrfs-dev \ + libseccomp-dev \ + pkg-config +RUN git config --global advice.detachedHead false FROM build-base-debian AS build-containerd ARG TARGETARCH @@ -72,10 +79,7 @@ WORKDIR /go/src/github.com/containerd/containerd RUN git checkout ${CONTAINERD_VERSION} && \ mkdir -p /out /out/$TARGETARCH && \ cp -a containerd.service /out -ENV CGO_ENABLED=1 -ENV GO111MODULE=off -# TODO: how to build containerd as static binaries? https://github.com/containerd/containerd/issues/6158 -RUN GO=xx-go make && \ +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 @@ -86,7 +90,7 @@ WORKDIR /go/src/github.com/opencontainers/runc RUN git checkout ${RUNC_VERSION} && \ mkdir -p /out ENV CGO_ENABLED=1 -RUN GO=xx-go make static && \ +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 @@ -100,27 +104,33 @@ 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 +ARG KUBO_VERSION +ARG TARGETARCH +RUN git clone https://github.com/ipfs/kubo.git /go/src/github.com/ipfs/kubo +WORKDIR /go/src/github.com/ipfs/kubo +RUN git checkout ${KUBO_VERSION} && \ + mkdir -p /out/${TARGETARCH} +ENV CGO_ENABLED=0 +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 -COPY . /go/src/github.com/containerd/nerdctl -WORKDIR /go/src/github.com/containerd/nerdctl +RUN git config --global advice.detachedHead false 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 -FROM build-base AS build-full +FROM build-base AS build-dependencies ARG TARGETARCH ENV GOARCH=${TARGETARCH} -RUN BINDIR=/out/bin make binaries install -WORKDIR /nowhere COPY ./Dockerfile.d/SHA256SUMS.d/ /SHA256SUMS.d -COPY README.md /out/share/doc/nerdctl/ -COPY docs /out/share/doc/nerdctl/docs +WORKDIR /nowhere RUN echo "${TARGETARCH:-amd64}" | sed -e s/amd64/x86_64/ -e s/arm64/aarch64/ | tee /target_uname_m -RUN mkdir -p /out/share/doc/nerdctl-full && \ - echo "# nerdctl (full distribution)" > /out/share/doc/nerdctl-full/README.md && \ - echo "- nerdctl: $(cd /go/src/github.com/containerd/nerdctl && git describe --tags)" >> /out/share/doc/nerdctl-full/README.md +RUN mkdir -p /out/share/doc/nerdctl-full && touch /out/share/doc/nerdctl-full/README.md 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 @@ -130,7 +140,7 @@ COPY --from=build-runc /out/runc.${TARGETARCH:-amd64} /out/bin/runc RUN echo "- runc: ${RUNC_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG CNI_PLUGINS_VERSION RUN fname="cni-plugins-${TARGETOS:-linux}-${TARGETARCH:-amd64}-${CNI_PLUGINS_VERSION}.tgz" && \ - curl -o "${fname}" -fSL "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 && \ @@ -138,10 +148,11 @@ RUN fname="cni-plugins-${TARGETOS:-linux}-${TARGETARCH:-amd64}-${CNI_PLUGINS_VER echo "- CNI plugins: ${CNI_PLUGINS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG BUILDKIT_VERSION RUN fname="buildkit-${BUILDKIT_VERSION}.${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fSL "https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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-runc && \ + 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 && \ 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 && \ @@ -151,8 +162,8 @@ RUN cd /out/lib/systemd/system && \ echo "# This file was converted from containerd.service, with \`sed -E '${sedcomm}'\`" >> buildkit.service ARG STARGZ_SNAPSHOTTER_VERSION RUN fname="stargz-snapshotter-${STARGZ_SNAPSHOTTER_VERSION}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fSL "https://github.com/containerd/stargz-snapshotter/releases/download/${STARGZ_SNAPSHOTTER_VERSION}/${fname}" && \ - curl -o "stargz-snapshotter.service" -fSL "https://raw.githubusercontent.com/containerd/stargz-snapshotter/${STARGZ_SNAPSHOTTER_VERSION}/script/config/etc/systemd/system/stargz-snapshotter.service" && \ + 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" && \ 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 && \ @@ -162,18 +173,12 @@ RUN fname="stargz-snapshotter-${STARGZ_SNAPSHOTTER_VERSION}-${TARGETOS:-linux}-$ ARG IMGCRYPT_VERSION RUN git clone https://github.com/containerd/imgcrypt.git /go/src/github.com/containerd/imgcrypt && \ cd /go/src/github.com/containerd/imgcrypt && \ + git checkout "${IMGCRYPT_VERSION}" && \ CGO_ENABLED=0 make && DESTDIR=/out make install && \ echo "- imgcrypt: ${IMGCRYPT_VERSION}" >> /out/share/doc/nerdctl-full/README.md -ARG ROOTLESSKIT_VERSION -RUN fname="rootlesskit-$(cat /target_uname_m).tar.gz" && \ - curl -o "${fname}" -fSL "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 SLIRP4NETNS_VERSION RUN fname="slirp4netns-$(cat /target_uname_m)" && \ - curl -o "${fname}" -fSL "https://github.com/rootless-containers/slirp4netns/releases/download/${SLIRP4NETNS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 && \ @@ -183,45 +188,43 @@ COPY --from=build-bypass4netns /out/${TARGETARCH:-amd64}/* /out/bin/ RUN echo "- bypass4netns: ${BYPASS4NETNS_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG FUSE_OVERLAYFS_VERSION RUN fname="fuse-overlayfs-$(cat /target_uname_m)" && \ - curl -o "${fname}" -fSL "https://github.com/containers/fuse-overlayfs/releases/download/${FUSE_OVERLAYFS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 fname="containerd-fuse-overlayfs-${CONTAINERD_FUSE_OVERLAYFS_VERSION/v}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fSL "https://github.com/containerd/fuse-overlayfs-snapshotter/releases/download/${CONTAINERD_FUSE_OVERLAYFS_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 KUBO_VERSION -RUN fname="kubo_${KUBO_VERSION}_${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fSL "https://github.com/ipfs/kubo/releases/download/${KUBO_VERSION}/${fname}" && \ - grep "${fname}" "/SHA256SUMS.d/kubo-${KUBO_VERSION}" | sha256sum -c && \ - tmpout=$(mktemp -d) && \ - tar -C ${tmpout} -xzf "${fname}" kubo/ipfs && \ - mv ${tmpout}/kubo/ipfs /out/bin/ && \ - echo "- Kubo (IPFS): ${KUBO_VERSION}" >> /out/share/doc/nerdctl-full/README.md ARG TINI_VERSION RUN fname="tini-static-${TARGETARCH:-amd64}" && \ - curl -o "${fname}" -fSL "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 fname="buildg-${BUILDG_VERSION}-${TARGETOS:-linux}-${TARGETARCH:-amd64}.tar.gz" && \ - curl -o "${fname}" -fSL "https://github.com/ktock/buildg/releases/download/${BUILDG_VERSION}/${fname}" && \ + curl -o "${fname}" -fsSL --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 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}" && \ + 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 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/ipfs: [Combination of MIT-only license and dual MIT/Apache-2.0 license](https://github.com/ipfs/kubo/blob/${KUBO_VERSION}/LICENSE)" >> /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 "- Other files: [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)" >> /out/share/doc/nerdctl-full/README.md && \ @@ -229,13 +232,20 @@ RUN echo "" >> /out/share/doc/nerdctl-full/README.md && \ mv /tmp/SHA256SUMS /out/share/doc/nerdctl-full/SHA256SUMS && \ chown -R 0:0 /out +FROM build-dependencies AS build-full +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 +COPY README.md /out/share/doc/nerdctl/ +COPY docs /out/share/doc/nerdctl/docs + FROM scratch AS out-full COPY --from=build-full /out / FROM ubuntu:${UBUNTU_VERSION} AS base # fuse3 is required by stargz snapshotter -RUN apt-get update && \ - apt-get install -qq -y --no-install-recommends \ +RUN apt-get update -qq && apt-get install -qq -y --no-install-recommends \ apparmor \ bash-completion \ ca-certificates curl \ @@ -243,7 +253,7 @@ RUN apt-get update && \ dbus dbus-user-session systemd systemd-sysv \ fuse3 ARG CONTAINERIZED_SYSTEMD_VERSION -RUN curl -L -o /docker-entrypoint.sh https://raw.githubusercontent.com/AkihiroSuda/containerized-systemd/${CONTAINERIZED_SYSTEMD_VERSION}/docker-entrypoint.sh && \ +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=out-full / /usr/local/ RUN perl -pi -e 's/multi-user.target/docker-entrypoint.target/g' /usr/local/lib/systemd/system/*.service && \ @@ -267,12 +277,13 @@ RUN go env GOVERSION > /GOVERSION FROM base AS test-integration ARG DEBIAN_FRONTEND=noninteractive # `expect` package contains `unbuffer(1)`, which is used for emulating TTY for testing -RUN apt-get update && \ - apt-get install -qq -y \ - expect git +RUN apt-get update -qq && apt-get install -qq --no-install-recommends \ + expect \ + git \ + make COPY --from=goversion /GOVERSION /GOVERSION ARG TARGETARCH -RUN curl -L https://golang.org/dl/$(cat /GOVERSION).linux-${TARGETARCH:-amd64}.tar.gz | tar xzvC /usr/local +RUN curl -fsSL --proto '=https' --tlsv1.2 https://golang.org/dl/$(cat /GOVERSION).linux-${TARGETARCH:-amd64}.tar.gz | tar xzvC /usr/local ENV PATH=/usr/local/go/bin:$PATH ARG GOTESTSUM_VERSION RUN GOBIN=/usr/local/bin go install gotest.tools/gotestsum@${GOTESTSUM_VERSION} @@ -281,35 +292,43 @@ WORKDIR /go/src/github.com/containerd/nerdctl VOLUME /tmp ENV CGO_ENABLED=0 # copy cosign binary for integration test -COPY --from=gcr.io/projectsigstore/cosign:v2.0.0@sha256:728944a9542a7235b4358c4ab2bcea855840e9d4b9594febca5c2207f5da7f38 /ko-app/cosign /usr/local/bin/cosign +COPY --from=ghcr.io/sigstore/cosign/cosign:v2.2.3@sha256:8fc9cad121611e8479f65f79f2e5bea58949e8a87ffac2a42cb99cf0ff079ba7 /ko-app/cosign /usr/local/bin/cosign +# 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 # 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 COPY ./Dockerfile.d/test-integration-ipfs-offline.service /usr/local/lib/systemd/system/ COPY ./Dockerfile.d/test-integration-buildkit-nerdctl-test.service /usr/local/lib/systemd/system/ +COPY ./Dockerfile.d/test-integration-soci-snapshotter.service /usr/local/lib/systemd/system/ RUN cp /usr/local/bin/tini /usr/local/bin/tini-custom +# using test integration containerd config +COPY ./Dockerfile.d/test-integration-etc_containerd_config.toml /etc/containerd/config.toml # install ipfs service. avoid using 5001(api)/8080(gateway) which are reserved by tests. -RUN systemctl enable test-integration-ipfs-offline test-integration-buildkit-nerdctl-test && \ +RUN systemctl enable test-integration-ipfs-offline test-integration-buildkit-nerdctl-test test-integration-soci-snapshotter && \ ipfs init && \ ipfs config Addresses.API "/ip4/127.0.0.1/tcp/5888" && \ ipfs config Addresses.Gateway "/ip4/127.0.0.1/tcp/5889" # install nydus components ARG NYDUS_VERSION -RUN curl -L -o nydus-static.tgz "https://github.com/dragonflyoss/image-service/releases/download/${NYDUS_VERSION}/nydus-static-${NYDUS_VERSION}-linux-${TARGETARCH}.tgz" && \ +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" && \ tar xzf nydus-static.tgz && \ mv nydus-static/nydus-image nydus-static/nydusd nydus-static/nydusify /usr/bin/ && \ rm nydus-static.tgz -CMD ["gotestsum", "--format=testname", "--rerun-fails=2", "--packages=github.com/containerd/nerdctl/cmd/nerdctl/...", \ - "--", "-timeout=30m", "-args", "-test.kill-daemon"] +CMD ["./hack/test-integration.sh"] FROM test-integration AS test-integration-rootless # Install SSH for creating systemd user session. # (`sudo` does not work for this purpose, # OTOH `machinectl shell` can create the session but does not propagate exit code) -RUN apt-get update && \ - apt-get install -qq -y \ +RUN apt-get update -qq && apt-get install -qq --no-install-recommends \ uidmap \ - openssh-server openssh-client -# TODO: update containerized-systemd to enable sshd by default, or allow `systemctl wants sshd` here + openssh-server \ + openssh-client +# TODO: update containerized-systemd to enable sshd by default, or allow `systemctl wants ssh` here RUN ssh-keygen -q -t rsa -f /root/.ssh/id_rsa -N '' && \ useradd -m -s /bin/bash rootless && \ mkdir -p -m 0700 /home/rootless/.ssh && \ @@ -320,12 +339,9 @@ COPY ./Dockerfile.d/etc_systemd_system_user@.service.d_delegate.conf /etc/system # ipfs daemon for rootless containerd will be enabled in /test-integration-rootless.sh RUN systemctl disable test-integration-ipfs-offline VOLUME /home/rootless/.local/share -RUN go test -o /usr/local/bin/nerdctl.test -c ./cmd/nerdctl COPY ./Dockerfile.d/test-integration-rootless.sh / -CMD ["/test-integration-rootless.sh", \ - "gotestsum", "--format=testname", "--rerun-fails=2", "--raw-command", \ - "--", "/usr/local/go/bin/go", "tool", "test2json", "-t", "-p", "github.com/containerd/nerdctl/cmd/nerdctl", \ - "/usr/local/bin/nerdctl.test", "-test.v", "-test.timeout=30m", "-test.kill-daemon"] +RUN chmod a+rx /test-integration-rootless.sh +CMD ["/test-integration-rootless.sh", "./hack/test-integration.sh"] # test for CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns FROM test-integration-rootless AS test-integration-rootless-port-slirp4netns diff --git a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.11.6 b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.11.6 deleted file mode 100644 index ca3b6f2f90d..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.11.6 +++ /dev/null @@ -1,2 +0,0 @@ -3f66f5bfbe509aadf1c21a26acfa472fe4c19046aa00a2d59b99733da867cd76 buildkit-v0.11.6.linux-amd64.tar.gz -82b7452ffea166d3ef445597f9dbe3fa57c4d651e51ca7a9a581199116905524 buildkit-v0.11.6.linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/buildkit-v0.19.0 b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.19.0 new file mode 100644 index 00000000000..440c6b431ea --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/buildkit-v0.19.0 @@ -0,0 +1,2 @@ +9993fdd8b454e541ac14a1adf4bf53d271dbc8f3aafde45894bf689604a0a5cf buildkit-v0.19.0.linux-amd64.tar.gz +be7f7922d8f5eea02704cd707fb62b5a18e272452243804601b523ae6bef0ef5 buildkit-v0.19.0.linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.3.0 b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.3.0 deleted file mode 100644 index 202f83df0ab..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.3.0 +++ /dev/null @@ -1,2 +0,0 @@ -754a71ed60a4bd08726c3af705a7d55ee3df03122b12e389fdba4bea35d7dd7e cni-plugins-linux-amd64-v1.3.0.tgz -de7a666fd6ad83a228086bd55756db62ef335a193d1b143d910b69f079e30598 cni-plugins-linux-arm64-v1.3.0.tgz diff --git a/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.6.2 b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.6.2 new file mode 100644 index 00000000000..109168fb84f --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/cni-plugins-v1.6.2 @@ -0,0 +1,2 @@ +b8e811578fb66023f90d2e238d80cec3bdfca4b44049af74c374d4fae0f9c090 cni-plugins-linux-amd64-v1.6.2.tgz +01e0e22acc7f7004e4588c1fe1871cc86d7ab562cd858e1761c4641d89ebfaa4 cni-plugins-linux-arm64-v1.6.2.tgz diff --git a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v1.0.6 b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v1.0.6 deleted file mode 100644 index f5731f42f9d..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v1.0.6 +++ /dev/null @@ -1,6 +0,0 @@ -2fbf5532021a558a965358e765c21b4dbe891e00ac68d18ab088d3cea4a41613 containerd-fuse-overlayfs-1.0.6-linux-amd64.tar.gz -9d983f341b7fb980bb7728851aaab41f706cbc55387940800fabcd7f01b33f24 containerd-fuse-overlayfs-1.0.6-linux-arm-v7.tar.gz -27c40aea2dc37fc59dae790c87193190a8360a310fa8df16fcbec165a46d6163 containerd-fuse-overlayfs-1.0.6-linux-arm64.tar.gz -ad92ee6c7ecbf7b1f2b78f13eccf5ca82fc5349ef93d9116bef49b6767fbc146 containerd-fuse-overlayfs-1.0.6-linux-ppc64le.tar.gz -971243ff3da618dbdcc28ef243ed08e834f7acfc164feebd7d25502938d5cae8 containerd-fuse-overlayfs-1.0.6-linux-riscv64.tar.gz -691c07478f009858cbba66300a0da689fe60daf7fbc13609759ff8ef14ac2a97 containerd-fuse-overlayfs-1.0.6-linux-s390x.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.1 b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.1 new file mode 100644 index 00000000000..6596447644d --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/containerd-fuse-overlayfs-v2.1.1 @@ -0,0 +1,6 @@ +2061a4064d163544f69e36fe56d008ab90f791906d5a96bddf87d3151fdde836 containerd-fuse-overlayfs-2.1.1-linux-amd64.tar.gz +99d08b0f41ede108f36efb9b5d8e0613be69336785cf97a73074487b52d9e71e containerd-fuse-overlayfs-2.1.1-linux-arm-v7.tar.gz +2219bf91d943480ce7021d6fce956379050757a500d36540b4372d45616c74eb containerd-fuse-overlayfs-2.1.1-linux-arm64.tar.gz +a2515f00553334b23470d52b088e49c3aa69aa9d66163dc14f188684bc8c774d containerd-fuse-overlayfs-2.1.1-linux-ppc64le.tar.gz +ae0fc07af2d34fb4c599364f82570ec43fed07f1892e493726f5414ecf8c8908 containerd-fuse-overlayfs-2.1.1-linux-riscv64.tar.gz +1200244a100b2433cc98a7ec8a0138073e9ad1c5e11ed503f5d2b3063dd40197 containerd-fuse-overlayfs-2.1.1-linux-s390x.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.12 b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.12 deleted file mode 100644 index 5da63cd7e47..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.12 +++ /dev/null @@ -1,6 +0,0 @@ -6d2813904de47350adf6d61998c97d5c262fc01b4cf4a0b70be21097aa2acda4 fuse-overlayfs-aarch64 -357763baab2e4cfd7de07e0683252d5ea0dde5e5c7bd8aba99662e25f5e63329 fuse-overlayfs-armv7l -5b781adc8861f095719a3d61d936621f8a17bf68cc78c73c0e693dafb679831f fuse-overlayfs-ppc64le -8b147b181c068c858bb8e8c4966817f3f884d9aad6ee7860f2a46e82c70d02ba fuse-overlayfs-riscv64 -e3fabc7529071e490b0a5e4848feaf3a0d984b50ebe84f0c4e4686503e1d5f07 fuse-overlayfs-s390x -152318f61b1fbe91ddaef42ed31f4a173d288af1376484bc54aff892c53f4a90 fuse-overlayfs-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.14 b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.14 new file mode 100644 index 00000000000..4ef7dca0da1 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/fuse-overlayfs-v1.14 @@ -0,0 +1,2 @@ +bf2c19b80e68afe1f53bae7a08cc9e7fb2f1b49bfdb9e5b49ab87cbe80b97cd1 fuse-overlayfs-aarch64 +4817a8896a9e6f0433080f88f5b71dec931e8829a89d64c71af94b0630ccb4a9 fuse-overlayfs-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/kubo-v0.20.0 b/Dockerfile.d/SHA256SUMS.d/kubo-v0.20.0 deleted file mode 100644 index f98990e14a3..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/kubo-v0.20.0 +++ /dev/null @@ -1,3 +0,0 @@ -# From https://github.com/ipfs/kubo/releases -46f3f14d75640dfedb0ee79ccb87eefa30da5c00b0af2c30f02f13454f5ab072 kubo_v0.20.0_linux-amd64.tar.gz -a073d4e9eefd5f7c1ee24f2c9d0a8975591beea34c49d8fee4c0ea731393ef84 kubo_v0.20.0_linux-arm64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.0 b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.0 deleted file mode 100644 index 0a944324c85..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.0 +++ /dev/null @@ -1,6 +0,0 @@ -3e5e0dd91f53fda3591098de368727297b56ce86ce4c121fac483251b72069bd rootlesskit-aarch64.tar.gz -d4faf36efa88d2b9ea7f2506873fc3562e8d030da18106e9c8eb6d84d4b3edc8 rootlesskit-armv7l.tar.gz -e47a019fdfa043ddcff8aee2593383c66935874e8328d7a8138966a28106b70f rootlesskit-ppc64le.tar.gz -9f15b93ff7b991705db5eafe769405393ecee51c7adb553f9c255c01cfb92d9a rootlesskit-riscv64.tar.gz -0072436fb718c79b3bea02b3d6003939a9f4cb6d0bedf3d31f14c8b4456a6b49 rootlesskit-s390x.tar.gz -503257d14960ff04b5c90c14a4f4f3410244a1468c144430251c87971ac714d4 rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.1 b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.1 new file mode 100644 index 00000000000..73980f90118 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v1.1.1 @@ -0,0 +1,6 @@ +b74c577abd6ad721e0b7e10a74f4c5ac26cb3afe005ad3d28d4d7912c356079f rootlesskit-aarch64.tar.gz +95c27e6808c942c67ab93d94e37bada3a62cfc47de848101889f8e3ba5c9f7dd rootlesskit-armv7l.tar.gz +df35c74cd030e1b3978f28d1cb7c909da2ab962fb0c9369463d43a89b9f16cc2 rootlesskit-ppc64le.tar.gz +79af3e96e9d6deddc5faa4680de7e28120ae333386c48a30e79fe156f17bad9b rootlesskit-riscv64.tar.gz +32da9a11b67340ff498de8a3268673277a1e1d9e9d8d5f619bbf09305beaaa6c rootlesskit-s390x.tar.gz +3c83affbb405cafe2d32e2e24462af9b4dcfa19e3809030012ad0d4e3fd49e8f rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.2 b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.2 new file mode 100644 index 00000000000..9c7f6e338d0 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/rootlesskit-v2.3.2 @@ -0,0 +1,6 @@ +0a4ed18c6794bfe5821cc6548f52b26b3b2296170f05df194c2073545200d968 rootlesskit-aarch64.tar.gz +f9faaf3b91e02764eb8308c7a7da7de55311de9fea665ca1b2632421b9286bcc rootlesskit-armv7l.tar.gz +fc1120af52071dc2a6984eec8dfc74f5d973fb28fa6c6d7ec77523a636ebf641 rootlesskit-ppc64le.tar.gz +acc1d39483df101bfa204c8dbf61d2e7ac85b246b3e9c606dabeaaeededd0130 rootlesskit-riscv64.tar.gz +9e1c29b1b82162a71d435edf80898adcb5a41c0ec51202c36856276689eb7b52 rootlesskit-s390x.tar.gz +5d402d7995f1e2c369240de3c6f8eb4cc2a3d1f0f4877ac5362044b2e83962e9 rootlesskit-x86_64.tar.gz diff --git a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.2.0 b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.2.0 deleted file mode 100644 index 77c7008dec2..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.2.0 +++ /dev/null @@ -1,6 +0,0 @@ -fb82c4a8e63fe1acc0bdc92300080b8c80e514e409dc70fd593045df644ac306 slirp4netns-aarch64 -31e2555c320746083c11c959f88313b5ac277a79a804b8c59c4b030b2ba8f41b slirp4netns-armv7l -dcae54bc1ab854bb71aa98c9ec2ad86f6817acaa75910ccf7d899219fa926cea slirp4netns-ppc64le -2d420e3e1ab51526d9e05ce0020a505775ffcf00f64d5244973a0e0602d8c864 slirp4netns-riscv64 -65925c8d6bea7bd380a6271b06ca63dab425599ab759eb055072cff9d7f33fb9 slirp4netns-s390x -11080fdfb2c47b99f2b0c2b72d92cc64400d0eaba11c1ec34f779e17e8844360 slirp4netns-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.1 b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.1 new file mode 100644 index 00000000000..4d0d9ea9444 --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/slirp4netns-v1.3.1 @@ -0,0 +1,6 @@ +2dd9aac6c2e3203e53cb7b6e4b9fc7123e4e4a9716c8bb1d95951853059a6af5 slirp4netns-aarch64 +ed618c0f2c74014bb736e9e427e18c8791ad9d68311872a41b06fac0d7cb9ef2 slirp4netns-armv7l +a10f70209cee0dd0532fea0e8b6bfde5d16dec5206fd4b3387d861721456de66 slirp4netns-ppc64le +38209015c2f3f4619d9fc46610852887910f33c7a0b96f7d2aa835a7bbc73f31 slirp4netns-riscv64 +9f42718455b1f9cf4b6f0efee314b78e860b8c36dbbb6290f09c8fbedda9ff8a slirp4netns-s390x +4bc5d6c311f9fa7ae00ce54aefe10c2afaf0800fe9e99f32616a964ed804a9e1 slirp4netns-x86_64 diff --git a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.14.3 b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.14.3 deleted file mode 100644 index f0a635ee028..00000000000 --- a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.14.3 +++ /dev/null @@ -1,3 +0,0 @@ -3f5c268eac6c68f1caeb433321a07e6332ed304e9b3ae428f183982ce6df91d6 stargz-snapshotter-v0.14.3-linux-amd64.tar.gz -5788407890786210ea9552a62fd653cea3acc48ffd354f66e5821967128d06d9 stargz-snapshotter-v0.14.3-linux-arm64.tar.gz -f1cf855870af16a653d8acb9daa3edf84687c2c05323cb958f078fb148af3eec stargz-snapshotter.service diff --git a/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 new file mode 100644 index 00000000000..e9b2bfa457c --- /dev/null +++ b/Dockerfile.d/SHA256SUMS.d/stargz-snapshotter-v0.16.3 @@ -0,0 +1,3 @@ +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/test-integration-etc_containerd_config.toml b/Dockerfile.d/test-integration-etc_containerd_config.toml new file mode 100644 index 00000000000..d37df58da75 --- /dev/null +++ b/Dockerfile.d/test-integration-etc_containerd_config.toml @@ -0,0 +1,12 @@ +version = 2 + +# Enable stargz snapshotter +[proxy_plugins] + [proxy_plugins.stargz] + type = "snapshot" + address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock" + +# Enable soci snapshotter + [proxy_plugins.soci] + type = "snapshot" + address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock" diff --git a/Dockerfile.d/test-integration-ipfs-offline.service b/Dockerfile.d/test-integration-ipfs-offline.service index cd16bdcbadf..af0662250c5 100644 --- a/Dockerfile.d/test-integration-ipfs-offline.service +++ b/Dockerfile.d/test-integration-ipfs-offline.service @@ -3,6 +3,7 @@ Description=ipfs daemon for integration test (offline) [Service] ExecStart=ipfs daemon --init --offline +Environment=IPFS_PATH="%h/.ipfs" [Install] WantedBy=docker-entrypoint.target diff --git a/Dockerfile.d/test-integration-rootless.sh b/Dockerfile.d/test-integration-rootless.sh index 6231f34f4d6..4bdbf0fa4eb 100755 --- a/Dockerfile.d/test-integration-rootless.sh +++ b/Dockerfile.d/test-integration-rootless.sh @@ -21,13 +21,13 @@ if [[ "$(id -u)" = "0" ]]; then nerdctl apparmor load fi - : "${WORKAROUND_CIRRUS:=}" - if [[ "$WORKAROUND_CIRRUS" = "1" ]]; then - touch /workaround-cirrus + : "${WORKAROUND_ISSUE_622:=}" + if [[ "$WORKAROUND_ISSUE_622" = "1" ]]; then + touch /workaround-issue-622 fi # Switch to the rootless user via SSH - systemctl start sshd + systemctl start ssh exec ssh -o StrictHostKeyChecking=no rootless@localhost "$0" "$@" else containerd-rootless-setuptool.sh install @@ -35,8 +35,8 @@ else containerd-rootless-setuptool.sh nsenter -- sh -euc 'echo "options use-vc" >>/etc/resolv.conf' fi - if [[ -e /workaround-cirrus ]]; then - echo "WORKAROUND_CIRRUS: Not enabling BuildKit (https://github.com/containerd/nerdctl/issues/622)" >&2 + if [[ -e /workaround-issue-622 ]]; then + echo "WORKAROUND_ISSUE_622: Not enabling BuildKit (https://github.com/containerd/nerdctl/issues/622)" >&2 else CONTAINERD_NAMESPACE="nerdctl-test" containerd-rootless-setuptool.sh install-buildkit-containerd fi @@ -48,7 +48,7 @@ else [proxy_plugins] [proxy_plugins."stargz"] type = "snapshot" - address = "/run/user/1000/containerd-stargz-grpc/containerd-stargz-grpc.sock" + address = "/run/user/$(id -u)/containerd-stargz-grpc/containerd-stargz-grpc.sock" EOF systemctl --user restart containerd.service containerd-rootless-setuptool.sh -- install-ipfs --init --offline # offline ipfs daemon for testing @@ -56,5 +56,8 @@ EOF systemctl --user restart stargz-snapshotter.service export IPFS_PATH="/home/rootless/.local/share/ipfs" containerd-rootless-setuptool.sh install-bypass4netnsd - exec "$@" + # Once ssh-ed, we lost the Dockerfile working dir, so, get back in the nerdctl checkout + cd /go/src/github.com/containerd/nerdctl + # We also lose the PATH (and SendEnv=PATH would require sshd config changes) + exec env PATH="/usr/local/go/bin:$PATH" "$@" fi diff --git a/Dockerfile.d/test-integration-soci-snapshotter.service b/Dockerfile.d/test-integration-soci-snapshotter.service new file mode 100644 index 00000000000..5964702ac6a --- /dev/null +++ b/Dockerfile.d/test-integration-soci-snapshotter.service @@ -0,0 +1,15 @@ +[Unit] +Description=soci snapshotter containerd plugin for integration test +Documentation=https://github.com/awslabs/soci-snapshotter +After=network.target +Before=containerd.service + +[Service] +Type=notify +ExecStartPre=/bin/bash -c 'mkdir -p /var/lib/soci-snapshotter-grpc && mount -t tmpfs none /var/lib/soci-snapshotter-grpc' +ExecStart=/usr/local/bin/soci-snapshotter-grpc +Restart=always +RestartSec=5 + +[Install] +WantedBy=docker-entrypoint.target diff --git a/EMERITUS.md b/EMERITUS.md new file mode 100644 index 00000000000..ed17a87f02f --- /dev/null +++ b/EMERITUS.md @@ -0,0 +1,21 @@ +See [`MAINTAINERS`](./MAINTAINERS) for the current active maintainers. +- - - +# nerdctl Emeritus Maintainers + +## Committers +### Ye Sijun ([@junnplus](https://github.com/junnplus)) +Ye Sijun (GitHub ID [@junnplus](https://github.com/junnplus)) served as +a Committer of nerdctl from November 2022 to June 2024. +Prior to his role as a Committer, Sijun served as a Reviewer since February 2022. + +Sijun has made [significant improvements](https://github.com/containerd/nerdctl/pulls?q=author%3Ajunnplus+) +especially to `nerdctl compose`, IPAM, and cosign integration. + +## Reviewers +### Hanchin Hsieh ([@yuchanns](https://github.com/yuchanns)) +Hanchin Hsieh (GitHub ID [@yuchanns](https://github.com/yuchanns)) served as +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). diff --git a/MAINTAINERS b/MAINTAINERS index 1bdc6f9e2cf..8fbc21ebdf6 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -15,11 +15,13 @@ "ktock","Kohei Tokunaga","ktokunaga.mail@gmail.com","" "fahedouch","Fahed Dorgaa","fahed.dorgaa@gmail.com","EE7A 5503 CE0D 38AC 5B95 A500 F35F F497 60A8 65FA" "Zheaoli", "Zheao Li", "me@manjusaka.me","6E0D D9FA BAD5 AF61 D884 01EE 878F 445D 9C6C E65E" -"junnplus","Ye Sijun","junnplus@gmail.com","" +"djdongjin", "Jin Dong", "djdongjin95@gmail.com","" +"yankay", "Kay Yan", "kay.yan@daocloud.io", "" # REVIEWERS # GitHub ID, Name, Email address, GPG fingerprint "jsturtevant","James Sturtevant","jstur@microsoft.com","" -"yuchanns", "Hanchin Hsieh", "me@yuchanns.xyz","" "manugupt1", "Manu Gupta", "manugupt1@gmail.com","FCA9 504A 4118 EA5C F466 CC30 A5C3 A8F4 E7FE 9E10" -"djdongjin", "Jin Dong", "djdongjin95@gmail.com","" + +# EMERITUS +# See EMERITUS.md diff --git a/Makefile b/Makefile index d4a76cb6cd8..ae4e18c94f3 100644 --- a/Makefile +++ b/Makefile @@ -18,26 +18,37 @@ # Licensed under the Apache License, Version 2.0 # ----------------------------------------------------------------------------- +DOCKER ?= docker GO ?= go -GOOS ?= $(shell go env GOOS) +GOOS ?= $(shell $(GO) env GOOS) ifeq ($(GOOS),windows) BIN_EXT := .exe endif -PACKAGE := github.com/containerd/nerdctl -BINDIR ?= /usr/local/bin +PACKAGE := github.com/containerd/nerdctl/v2 -VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags) -VERSION_TRIMMED := $(VERSION:v%=%) -REVISION ?= $(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet --exit-code; then echo .m; fi) +# distro builders might wanna override these +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +DATADIR ?= $(PREFIX)/share +DOCDIR ?= $(DATADIR)/doc -GO_BUILD_LDFLAGS ?= -s -w -export GO_BUILD=GO111MODULE=on CGO_ENABLED=0 GOOS=$(GOOS) $(GO) build -ldflags "$(GO_BUILD_LDFLAGS) -X $(PACKAGE)/pkg/version.Version=$(VERSION) -X $(PACKAGE)/pkg/version.Revision=$(REVISION)" +MAKEFILE_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +VERSION ?= $(shell git -C $(MAKEFILE_DIR) describe --match 'v[0-9]*' --dirty='.m' --always --tags) +VERSION_TRIMMED := $(VERSION:v%=%) +REVISION ?= $(shell git -C $(MAKEFILE_DIR) rev-parse HEAD)$(shell if ! git -C $(MAKEFILE_DIR) diff --no-ext-diff --quiet --exit-code; then echo .m; fi) ifdef VERBOSE VERBOSE_FLAG := -v + VERBOSE_FLAG_LONG := --verbose endif +GO_BUILD_LDFLAGS ?= -s -w +GO_BUILD_FLAGS ?= +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)" + +recursive_wildcard=$(wildcard $1$2) $(foreach e,$(wildcard $1*),$(call recursive_wildcard,$e/,$2)) + all: binaries help: @@ -46,62 +57,87 @@ help: @echo " * 'install' - Install binaries to system locations." @echo " * 'binaries' - Build nerdctl." @echo " * 'clean' - Clean artifacts." + @echo " * 'lint' - Run various linters." nerdctl: - $(GO_BUILD) $(VERBOSE_FLAG) -o $(CURDIR)/_output/nerdctl$(BIN_EXT) $(PACKAGE)/cmd/nerdctl + $(GO_BUILD) $(GO_BUILD_FLAGS) $(VERBOSE_FLAG) -o $(CURDIR)/_output/nerdctl$(BIN_EXT) ./cmd/nerdctl clean: find . -name \*~ -delete find . -name \#\* -delete - rm -rf _output/* vendor + rm -rf $(CURDIR)/_output/* $(MAKEFILE_DIR)/vendor + +lint: lint-go lint-imports lint-yaml lint-shell + +lint-go: + cd $(MAKEFILE_DIR) && GOOS=linux golangci-lint run $(VERBOSE_FLAG_LONG) ./... && \ + GOOS=windows golangci-lint run $(VERBOSE_FLAG_LONG) ./... && \ + GOOS=freebsd golangci-lint run $(VERBOSE_FLAG_LONG) ./... + +lint-imports: + cd $(MAKEFILE_DIR) && ./hack/lint-imports.sh + +lint-fix-imports: + cd $(MAKEFILE_DIR) && goimports-reviser -company-prefixes "github.com/containerd" ./... + +lint-yaml: + cd $(MAKEFILE_DIR) && yamllint . + +lint-shell: $(call recursive_wildcard,$(MAKEFILE_DIR)/,*.sh) + shellcheck -a -x $^ + +test-unit: + go test -v $(MAKEFILE_DIR)/pkg/... binaries: nerdctl install: install -D -m 755 $(CURDIR)/_output/nerdctl $(DESTDIR)$(BINDIR)/nerdctl - install -D -m 755 $(CURDIR)/extras/rootless/containerd-rootless.sh $(DESTDIR)$(BINDIR)/containerd-rootless.sh - install -D -m 755 $(CURDIR)/extras/rootless/containerd-rootless-setuptool.sh $(DESTDIR)$(BINDIR)/containerd-rootless-setuptool.sh + install -D -m 755 $(MAKEFILE_DIR)/extras/rootless/containerd-rootless.sh $(DESTDIR)$(BINDIR)/containerd-rootless.sh + install -D -m 755 $(MAKEFILE_DIR)/extras/rootless/containerd-rootless-setuptool.sh $(DESTDIR)$(BINDIR)/containerd-rootless-setuptool.sh + install -D -m 644 -t $(DESTDIR)$(DOCDIR)/nerdctl $(MAKEFILE_DIR)/docs/*.md + +# Note that these options will not work on macOS - unless you use gnu-tar instead of tar +TAR_OWNER0_FLAGS=--owner=0 --group=0 +TAR_FLATTEN_FLAGS=--transform 's/.*\///g' define make_artifact_full_linux - DOCKER_BUILDKIT=1 docker build --output type=tar,dest=$(CURDIR)/_output/nerdctl-full-$(VERSION_TRIMMED)-linux-$(1).tar --target out-full --platform $(1) --build-arg GO_VERSION $(CURDIR) + $(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) gzip -9 $(CURDIR)/_output/nerdctl-full-$(VERSION_TRIMMED)-linux-$(1).tar endef -TAR_OWNER0_FLAGS=--owner=0 --group=0 -TAR_FLATTEN_FLAGS=--transform 's/.*\///g' - artifacts: clean - GOOS=linux GOARCH=amd64 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-amd64.tar.gz _output/nerdctl extras/rootless/* + GOOS=linux GOARCH=amd64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-amd64.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* - GOOS=linux GOARCH=arm64 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-arm64.tar.gz _output/nerdctl extras/rootless/* + GOOS=linux GOARCH=arm64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-arm64.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* - GOOS=linux GOARCH=arm GOARM=7 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-arm-v7.tar.gz _output/nerdctl extras/rootless/* + 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=ppc64le make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-ppc64le.tar.gz _output/nerdctl 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/* - GOOS=linux GOARCH=riscv64 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-riscv64.tar.gz _output/nerdctl extras/rootless/* + GOOS=linux GOARCH=riscv64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-riscv64.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* - GOOS=linux GOARCH=s390x make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-s390x.tar.gz _output/nerdctl extras/rootless/* + GOOS=linux GOARCH=s390x make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-linux-s390x.tar.gz $(CURDIR)/_output/nerdctl $(MAKEFILE_DIR)/extras/rootless/* - GOOS=windows GOARCH=amd64 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-windows-amd64.tar.gz _output/nerdctl.exe + GOOS=windows GOARCH=amd64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-windows-amd64.tar.gz $(CURDIR)/_output/nerdctl.exe - GOOS=freebsd GOARCH=amd64 make -C $(CURDIR) binaries - tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-freebsd-amd64.tar.gz _output/nerdctl + GOOS=freebsd GOARCH=amd64 make -C $(CURDIR) -f $(MAKEFILE_DIR)/Makefile binaries + tar $(TAR_OWNER0_FLAGS) $(TAR_FLATTEN_FLAGS) -czvf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-freebsd-amd64.tar.gz $(CURDIR)/_output/nerdctl rm -f $(CURDIR)/_output/nerdctl $(CURDIR)/_output/nerdctl.exe $(call make_artifact_full_linux,amd64) $(call make_artifact_full_linux,arm64) - go mod vendor - tar $(TAR_OWNER0_FLAGS) -czf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-go-mod-vendor.tar.gz go.mod go.sum vendor + $(GO) -C $(MAKEFILE_DIR) mod vendor + tar $(TAR_OWNER0_FLAGS) -czf $(CURDIR)/_output/nerdctl-$(VERSION_TRIMMED)-go-mod-vendor.tar.gz $(MAKEFILE_DIR)/go.mod $(MAKEFILE_DIR)/go.sum $(MAKEFILE_DIR)/vendor .PHONY: \ help \ @@ -110,3 +146,7 @@ artifacts: clean binaries \ install \ artifacts + lint \ + lint-yaml \ + lint-go \ + lint-shell diff --git a/README.md b/README.md index 70377760f80..b0cb1698a95 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ # nerdctl: Docker-compatible CLI for containerd + + + + logo + + `nerdctl` is a Docker-compatible CLI for [contai**nerd**](https://containerd.io). ✅ Same UI/UX as `docker` @@ -125,11 +131,11 @@ Binaries are available here: In addition to containerd, the following components should be installed: - [CNI plugins](https://github.com/containernetworking/plugins): for using `nerdctl run`. - - v1.1.0 or later is highly recommended. Older versions require extra [CNI isolation plugin](https://github.com/AkihiroSuda/cni-isolation) for isolating bridge networks (`nerdctl network create`). + - v1.1.0 or later is highly recommended. - [BuildKit](https://github.com/moby/buildkit) (OPTIONAL): for using `nerdctl build`. BuildKit daemon (`buildkitd`) needs to be running. See also [the document about setting up BuildKit](./docs/build.md). - v0.11.0 or later is highly recommended. Some features, such as pruning caches with `nerdctl system prune`, do not work with older versions. - [RootlessKit](https://github.com/rootless-containers/rootlesskit) and [slirp4netns](https://github.com/rootless-containers/slirp4netns) (OPTIONAL): for [Rootless mode](./docs/rootless.md) - - RootlessKit needs to be v0.10.0 or later. v0.14.1 or later is recommended. + - RootlessKit needs to be v0.10.0 or later. v2.0.0 or later is recommended. - slirp4netns needs to be v0.4.0 or later. v1.1.7 or later is recommended. These dependencies are included in `nerdctl-full---.tar.gz`, but not included in `nerdctl---.tar.gz`. @@ -184,14 +190,12 @@ Also, `nerdctl` might be potentially useful for debugging Kubernetes clusters, b Major: -- On-demand image pulling (lazy-pulling) using [Stargz](./docs/stargz.md)/[Nydus](./docs/nydus.md)/[OverlayBD](./docs/overlaybd.md) Snapshotter: `nerdctl --snapshotter=stargz|nydus|overlaybd run IMAGE` . +- On-demand image pulling (lazy-pulling) using [Stargz](./docs/stargz.md)/[Nydus](./docs/nydus.md)/[OverlayBD](./docs/overlaybd.md)/[SOCI](./docs/soci.md) Snapshotter: `nerdctl --snapshotter=stargz|nydus|overlaybd|soci run IMAGE` . - [Image encryption and decryption using ocicrypt (imgcrypt)](./docs/ocicrypt.md): `nerdctl image (encrypt|decrypt) SRC DST` - [P2P image distribution using IPFS](./docs/ipfs.md): `nerdctl run ipfs://CID` . P2P image distribution (IPFS) is completely optional. Your host is NOT connected to any P2P network, unless you opt in to [install and run IPFS daemon](https://docs.ipfs.io/install/). -- Recursive read-only (RRO) bind-mount: `nerdctl run -v /mnt:/mnt:rro` (make children such as `/mnt/usb` to be read-only, too). - Requires kernel >= 5.12, and crun >= 1.4 or runc >= 1.1 (PR [#3272](https://github.com/opencontainers/runc/pull/3272)). - [Cosign integration](./docs/cosign.md): `nerdctl pull --verify=cosign` and `nerdctl push --sign=cosign`, and [in Compose](./docs/cosign.md#cosign-in-compose) -- [Accelerated rootless containers using bypass4netns](./docs/rootless.md): `nerdctl run --label nerdctl/bypass4netns=true` +- [Accelerated rootless containers using bypass4netns](./docs/rootless.md): `nerdctl run --annotation nerdctl/bypass4netns=true` Minor: @@ -205,11 +209,17 @@ Minor: - Better multi-platform support, e.g., `nerdctl pull --all-platforms IMAGE` - Applying an (existing) AppArmor profile to rootless containers: `nerdctl run --security-opt apparmor=`. Use `sudo nerdctl apparmor load` to load the `nerdctl-default` profile. +- Systemd compatibility support: `nerdctl run --systemd=always` Trivial: - Inspecting raw OCI config: `nerdctl container inspect --mode=native` . +## Features implemented in `nerdctl` ahead of Docker + +- Recursive read-only (RRO) bind-mount: `nerdctl run -v /mnt:/mnt:rro` (make children such as `/mnt/usb` to be read-only, too). + Requires kernel >= 5.12. +The same feature was later introduced in Docker v25 with a different syntax. nerdctl will support Docker v25 syntax too in the future. ## Similar tools - [`ctr`](https://github.com/containerd/containerd/tree/main/cmd/ctr): incompatible with Docker CLI, and not friendly to users. @@ -243,30 +253,11 @@ Run `make && sudo make install`. See the header of [`go.mod`](./go.mod) for the minimum supported version of Go. -Using `go install github.com/containerd/nerdctl/cmd/nerdctl` is possible, but unrecommended because it does not fill version strings printed in `nerdctl version` - -### Test suite - -#### Running unit tests - -Run `go test -v ./pkg/...` - -#### Running integration test suite against nerdctl - -Run `go test -exec sudo -v ./cmd/nerdctl/...` after `make && sudo make install`. - -For testing rootless mode, `-exec sudo` is not needed. - -To run tests in a container: - -```bash -docker build -t test-integration --target test-integration . -docker run -t --rm --privileged test-integration -``` +Using `go install github.com/containerd/nerdctl/v2/cmd/nerdctl` is possible, but unrecommended because it does not fill version strings printed in `nerdctl version` -#### Running integration test suite against Docker +### Testing -Run `go test -exec sudo -v ./cmd/nerdctl/... -args -test.target=docker` to ensure that the test suite is compatible with Docker. +See [testing nerdctl](docs/testing/README.md). ### Contributing to nerdctl diff --git a/Vagrantfile.freebsd b/Vagrantfile.freebsd new file mode 100644 index 00000000000..f3a2e4a4d4a --- /dev/null +++ b/Vagrantfile.freebsd @@ -0,0 +1,66 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# 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. + +# Vagrantfile for FreeBSD +Vagrant.configure("2") do |config| + config.vm.box = "generic/freebsd14" + + memory = 2048 + cpus = 1 + config.vm.provider :virtualbox do |v, o| + v.memory = memory + v.cpus = cpus + end + config.vm.provider :libvirt do |v| + v.memory = memory + v.cpus = cpus + end + + config.vm.synced_folder ".", "/vagrant", type: "rsync" + + config.vm.provision "install", type: "shell", run: "once" do |sh| + sh.inline = <<~SHELL + #!/usr/bin/env bash + set -eux -o pipefail + # `pkg install go` still installs Go 1.20 (March 2024) + pkg install -y go122 containerd runj + ln -s go122 /usr/local/bin/go + cd /vagrant + go install ./cmd/nerdctl + SHELL + end + + config.vm.provision "test-unit", type: "shell", run: "never" do |sh| + sh.inline = <<~SHELL + #!/usr/bin/env bash + set -eux -o pipefail + cd /vagrant + go test -v ./pkg/... + SHELL + end + + config.vm.provision "test-integration", type: "shell", run: "never" do |sh| + sh.inline = <<~SHELL + #!/usr/bin/env bash + set -eux -o pipefail + daemon -o containerd.out containerd + sleep 3 + /root/go/bin/nerdctl run --rm --net=none dougrabson/freebsd-minimal:13 echo "Nerdctl is up and running." + SHELL + end + +end diff --git a/cmd/nerdctl/apparmor_inspect_linux.go b/cmd/nerdctl/apparmor/apparmor_inspect_linux.go similarity index 87% rename from cmd/nerdctl/apparmor_inspect_linux.go rename to cmd/nerdctl/apparmor/apparmor_inspect_linux.go index 0071a22a824..acfec41a959 100644 --- a/cmd/nerdctl/apparmor_inspect_linux.go +++ b/cmd/nerdctl/apparmor/apparmor_inspect_linux.go @@ -14,16 +14,16 @@ limitations under the License. */ -package main +package apparmor import ( "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - - "github.com/containerd/nerdctl/pkg/cmd/apparmor" - "github.com/containerd/nerdctl/pkg/defaults" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/apparmor" + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func newApparmorInspectCommand() *cobra.Command { diff --git a/cmd/nerdctl/apparmor_linux.go b/cmd/nerdctl/apparmor/apparmor_linux.go similarity index 79% rename from cmd/nerdctl/apparmor_linux.go rename to cmd/nerdctl/apparmor/apparmor_linux.go index e39985e9294..d1fa8ff977a 100644 --- a/cmd/nerdctl/apparmor_linux.go +++ b/cmd/nerdctl/apparmor/apparmor_linux.go @@ -14,18 +14,20 @@ limitations under the License. */ -package main +package apparmor import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newApparmorCommand() *cobra.Command { +func NewApparmorCommand() *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "apparmor", Short: "Manage AppArmor profiles", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/nerdctl/apparmor/apparmor_linux_test.go b/cmd/nerdctl/apparmor/apparmor_linux_test.go new file mode 100644 index 00000000000..2cf255871ba --- /dev/null +++ b/cmd/nerdctl/apparmor/apparmor_linux_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 apparmor + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/apparmor_list_linux.go b/cmd/nerdctl/apparmor/apparmor_list_linux.go similarity index 94% rename from cmd/nerdctl/apparmor_list_linux.go rename to cmd/nerdctl/apparmor/apparmor_list_linux.go index aa0073b048e..f1483e99db3 100644 --- a/cmd/nerdctl/apparmor_list_linux.go +++ b/cmd/nerdctl/apparmor/apparmor_list_linux.go @@ -14,12 +14,13 @@ limitations under the License. */ -package main +package apparmor import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/apparmor" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/apparmor" ) func newApparmorLsCommand() *cobra.Command { diff --git a/cmd/nerdctl/apparmor_load_linux.go b/cmd/nerdctl/apparmor/apparmor_load_linux.go similarity index 90% rename from cmd/nerdctl/apparmor_load_linux.go rename to cmd/nerdctl/apparmor/apparmor_load_linux.go index 38b7547242b..ccd51f406d4 100644 --- a/cmd/nerdctl/apparmor_load_linux.go +++ b/cmd/nerdctl/apparmor/apparmor_load_linux.go @@ -14,14 +14,15 @@ limitations under the License. */ -package main +package apparmor import ( "fmt" - "github.com/containerd/nerdctl/pkg/cmd/apparmor" - "github.com/containerd/nerdctl/pkg/defaults" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/pkg/cmd/apparmor" + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func newApparmorLoadCommand() *cobra.Command { diff --git a/cmd/nerdctl/apparmor_unload_linux.go b/cmd/nerdctl/apparmor/apparmor_unload_linux.go similarity index 86% rename from cmd/nerdctl/apparmor_unload_linux.go rename to cmd/nerdctl/apparmor/apparmor_unload_linux.go index 92fc289eb31..2ba93809544 100644 --- a/cmd/nerdctl/apparmor_unload_linux.go +++ b/cmd/nerdctl/apparmor/apparmor_unload_linux.go @@ -14,14 +14,16 @@ limitations under the License. */ -package main +package apparmor import ( "fmt" - "github.com/containerd/nerdctl/pkg/cmd/apparmor" - "github.com/containerd/nerdctl/pkg/defaults" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/pkg/cmd/apparmor" + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func newApparmorUnloadCommand() *cobra.Command { @@ -46,5 +48,5 @@ func apparmorUnloadAction(cmd *cobra.Command, args []string) error { } func apparmorUnloadShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteApparmorProfiles(cmd) + return completion.ApparmorProfiles(cmd) } diff --git a/cmd/nerdctl/builder.go b/cmd/nerdctl/builder/builder.go similarity index 67% rename from cmd/nerdctl/builder.go rename to cmd/nerdctl/builder/builder.go index e758fa303e2..3e8e120f23c 100644 --- a/cmd/nerdctl/builder.go +++ b/cmd/nerdctl/builder/builder.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package builder import ( "fmt" @@ -22,23 +22,25 @@ import ( "os/exec" "strings" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/sirupsen/logrus" + "github.com/docker/go-units" "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/builder" ) -func newBuilderCommand() *cobra.Command { +func NewBuilderCommand() *cobra.Command { var builderCommand = &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "builder", Short: "Manage builds", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } builderCommand.AddCommand( - newBuildCommand(), + NewBuildCommand(), newBuilderPruneCommand(), newBuilderDebugCommand(), ) @@ -56,30 +58,77 @@ func newBuilderPruneCommand() *cobra.Command { SilenceErrors: true, } - AddStringFlag(buildPruneCommand, "buildkit-host", nil, defaults.BuildKitHost(), "BUILDKIT_HOST", "BuildKit address") + helpers.AddStringFlag(buildPruneCommand, "buildkit-host", nil, "", "BUILDKIT_HOST", "BuildKit address") + + buildPruneCommand.Flags().BoolP("all", "a", false, "Remove all unused build cache, not just dangling ones") + buildPruneCommand.Flags().BoolP("force", "f", false, "Do not prompt for confirmation") return buildPruneCommand } func builderPruneAction(cmd *cobra.Command, _ []string) error { - globalOptions, err := processRootCmdFlags(cmd) + options, err := processBuilderPruneOptions(cmd) if err != nil { return err } - buildkitHost, err := getBuildkitHost(cmd, globalOptions.Namespace) + + if !options.Force { + var msg string + + if options.All { + msg = "This will remove all build cache." + } else { + msg = "This will remove any dangling build cache." + } + + if confirmed, err := helpers.Confirm(cmd, fmt.Sprintf("WARNING! %s.", msg)); err != nil || !confirmed { + return err + } + } + + prunedObjects, err := builder.Prune(cmd.Context(), options) if err != nil { return err } - buildctlBinary, err := buildkitutil.BuildctlBinary() + + var totalReclaimedSpace int64 + + for _, prunedObject := range prunedObjects { + totalReclaimedSpace += prunedObject.Size + } + + fmt.Fprintf(cmd.OutOrStdout(), "Total: %s\n", units.BytesSize(float64(totalReclaimedSpace))) + + return nil +} + +func processBuilderPruneOptions(cmd *cobra.Command) (types.BuilderPruneOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { - return err + return types.BuilderPruneOptions{}, err + } + + buildkitHost, err := GetBuildkitHost(cmd, globalOptions.Namespace) + if err != nil { + return types.BuilderPruneOptions{}, err + } + + all, err := cmd.Flags().GetBool("all") + if err != nil { + return types.BuilderPruneOptions{}, err + } + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return types.BuilderPruneOptions{}, err } - buildctlArgs := buildkitutil.BuildctlBaseArgs(buildkitHost) - buildctlArgs = append(buildctlArgs, "prune") - logrus.Debugf("running %s %v", buildctlBinary, buildctlArgs) - buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...) - buildctlCmd.Env = os.Environ() - buildctlCmd.Stdout = cmd.OutOrStdout() - return buildctlCmd.Run() + + return types.BuilderPruneOptions{ + Stderr: cmd.OutOrStderr(), + GOptions: globalOptions, + BuildKitHost: buildkitHost, + All: all, + Force: force, + }, nil } func newBuilderDebugCommand() *cobra.Command { @@ -87,7 +136,7 @@ func newBuilderDebugCommand() *cobra.Command { var buildDebugCommand = &cobra.Command{ Use: "debug", Short: shortHelp, - PreRunE: checkExperimental("`nerdctl builder debug`"), + PreRunE: helpers.CheckExperimental("`nerdctl builder debug`"), RunE: builderDebugAction, SilenceUsage: true, SilenceErrors: true, @@ -102,7 +151,7 @@ func newBuilderDebugCommand() *cobra.Command { } func builderDebugAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -115,9 +164,7 @@ func builderDebugAction(cmd *cobra.Command, args []string) error { return err } buildgArgs := []string{"debug"} - if err != nil { - return err - } else if globalOptions.Debug { + if globalOptions.Debug { buildgArgs = append([]string{"--debug"}, buildgArgs...) } diff --git a/cmd/nerdctl/builder_build.go b/cmd/nerdctl/builder/builder_build.go similarity index 56% rename from cmd/nerdctl/builder_build.go rename to cmd/nerdctl/builder/builder_build.go index 3b68283df9a..f22bcb8d517 100644 --- a/cmd/nerdctl/builder_build.go +++ b/cmd/nerdctl/builder/builder_build.go @@ -14,25 +14,27 @@ limitations under the License. */ -package main +package builder import ( "errors" "fmt" "os" + "strconv" "strings" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/builder" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/strutil" - "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/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/builder" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) -func newBuildCommand() *cobra.Command { +func NewBuildCommand() *cobra.Command { var buildCommand = &cobra.Command{ Use: "build [flags] PATH", Short: "Build an image from a Dockerfile. Needs buildkitd to be running.", @@ -42,7 +44,8 @@ If Dockerfile is not present and -f is not specified, it will look for Container SilenceUsage: true, SilenceErrors: true, } - AddStringFlag(buildCommand, "buildkit-host", nil, defaults.BuildKitHost(), "BUILDKIT_HOST", "BuildKit address") + helpers.AddStringFlag(buildCommand, "buildkit-host", nil, "", "BUILDKIT_HOST", "BuildKit address") + buildCommand.Flags().StringArray("add-host", nil, "Add a custom host-to-IP mapping (format: \"host:ip\")") buildCommand.Flags().StringArrayP("tag", "t", nil, "Name and optionally a tag in the 'name:tag' format") buildCommand.Flags().StringP("file", "f", "", "Name of the Dockerfile") buildCommand.Flags().String("target", "", "Set the target build stage to build") @@ -50,17 +53,29 @@ If Dockerfile is not present and -f is not specified, it will look for Container buildCommand.Flags().Bool("no-cache", false, "Do not use cache when building the image") buildCommand.Flags().StringP("output", "o", "", "Output destination (format: type=local,dest=path)") buildCommand.Flags().String("progress", "auto", "Set type of progress output (auto, plain, tty). Use plain to show container output") + buildCommand.Flags().String("provenance", "", "Shorthand for \"--attest=type=provenance\"") + buildCommand.Flags().Bool("pull", false, "On true, always attempt to pull latest image version from remote. Default uses buildkit's default.") buildCommand.Flags().StringArray("secret", nil, "Secret file to expose to the build: id=mysecret,src=/local/secret") + buildCommand.Flags().StringArray("allow", nil, "Allow extra privileged entitlement, e.g. network.host, security.insecure") + buildCommand.RegisterFlagCompletionFunc("allow", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"network.host", "security.insecure"}, cobra.ShellCompDirectiveNoFileComp + }) + buildCommand.Flags().StringArray("attest", nil, "Attestation parameters (format: \"type=sbom,generator=image\")") buildCommand.Flags().StringArray("ssh", nil, "SSH agent socket or keys to expose to the build (format: default|[=|[,]])") buildCommand.Flags().BoolP("quiet", "q", false, "Suppress the build output and print image ID on success") + buildCommand.Flags().String("sbom", "", "Shorthand for \"--attest=type=sbom\"") buildCommand.Flags().StringArray("cache-from", nil, "External cache sources (eg. user/app:cache, type=local,src=path/to/dir)") buildCommand.Flags().StringArray("cache-to", nil, "Cache export destinations (eg. user/app:cache, type=local,dest=path/to/dir)") buildCommand.Flags().Bool("rm", true, "Remove intermediate containers after a successful build") - + buildCommand.Flags().String("network", "default", "Set type of network for build (format:network=default|none|host)") + buildCommand.RegisterFlagCompletionFunc("network", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"default", "host", "none"}, cobra.ShellCompDirectiveNoFileComp + }) // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" buildCommand.Flags().StringSlice("platform", []string{}, "Set target platform for build (e.g., \"amd64\", \"arm64\")") - buildCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + buildCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) + buildCommand.Flags().StringArray("build-context", []string{}, "Additional build contexts (e.g., name=path)") // #endregion buildCommand.Flags().String("iidfile", "", "Write the image ID to the file") @@ -70,11 +85,15 @@ If Dockerfile is not present and -f is not specified, it will look for Container } func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBuildOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.BuilderBuildOptions{}, err + } + buildKitHost, err := GetBuildkitHost(cmd, globalOptions.Namespace) if err != nil { return types.BuilderBuildOptions{}, err } - buildKitHost, err := getBuildkitHost(cmd, globalOptions.Namespace) + extraHosts, err := cmd.Flags().GetStringArray("add-host") if err != nil { return types.BuilderBuildOptions{}, err } @@ -90,9 +109,6 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu if buildContext == "-" || strings.Contains(buildContext, "://") { return types.BuilderBuildOptions{}, fmt.Errorf("unsupported build context: %q", buildContext) } - if err != nil { - return types.BuilderBuildOptions{}, err - } output, err := cmd.Flags().GetString("output") if err != nil { return types.BuilderBuildOptions{}, err @@ -125,10 +141,22 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu if err != nil { return types.BuilderBuildOptions{}, err } + var pull *bool + if cmd.Flags().Changed("pull") { + pullFlag, err := cmd.Flags().GetBool("pull") + if err != nil { + return types.BuilderBuildOptions{}, err + } + pull = &pullFlag + } secret, err := cmd.Flags().GetStringArray("secret") if err != nil { return types.BuilderBuildOptions{}, err } + allow, err := cmd.Flags().GetStringArray("allow") + if err != nil { + return types.BuilderBuildOptions{}, err + } ssh, err := cmd.Flags().GetStringArray("ssh") if err != nil { return types.BuilderBuildOptions{}, err @@ -153,33 +181,67 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu if err != nil { return types.BuilderBuildOptions{}, err } + network, err := cmd.Flags().GetString("network") + if err != nil { + return types.BuilderBuildOptions{}, err + } + + attest, err := cmd.Flags().GetStringArray("attest") + if err != nil { + return types.BuilderBuildOptions{}, err + } + sbom, err := cmd.Flags().GetString("sbom") + if err != nil { + return types.BuilderBuildOptions{}, err + } + if sbom != "" { + attest = append(attest, canonicalizeAttest("sbom", sbom)) + } + provenance, err := cmd.Flags().GetString("provenance") + if err != nil { + return types.BuilderBuildOptions{}, err + } + if provenance != "" { + attest = append(attest, canonicalizeAttest("provenance", provenance)) + } + extendedBuildCtx, err := cmd.Flags().GetStringArray("build-context") + if err != nil { + return types.BuilderBuildOptions{}, err + } + return types.BuilderBuildOptions{ - GOptions: globalOptions, - BuildKitHost: buildKitHost, - BuildContext: buildContext, - Output: output, - Tag: tagValue, - Progress: progress, - File: filename, - Target: target, - BuildArgs: buildArgs, - Label: label, - NoCache: noCache, - Secret: secret, - SSH: ssh, - CacheFrom: cacheFrom, - CacheTo: cacheTo, - Rm: rm, - IidFile: iidfile, - Quiet: quiet, - Platform: platform, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.OutOrStderr(), - Stdin: cmd.InOrStdin(), + GOptions: globalOptions, + BuildKitHost: buildKitHost, + BuildContext: buildContext, + Output: output, + Tag: tagValue, + Progress: progress, + File: filename, + Target: target, + BuildArgs: buildArgs, + Label: label, + NoCache: noCache, + Pull: pull, + Secret: secret, + Allow: allow, + Attest: attest, + SSH: ssh, + CacheFrom: cacheFrom, + CacheTo: cacheTo, + Rm: rm, + IidFile: iidfile, + Quiet: quiet, + Platform: platform, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.OutOrStderr(), + Stdin: cmd.InOrStdin(), + NetworkMode: network, + ExtendedBuildContext: extendedBuildCtx, + ExtraHosts: extraHosts, }, nil } -func getBuildkitHost(cmd *cobra.Command, namespace string) (string, error) { +func GetBuildkitHost(cmd *cobra.Command, namespace string) (string, error) { if cmd.Flags().Changed("buildkit-host") || os.Getenv("BUILDKIT_HOST") != "" { // If address is explicitly specified, use it. buildkitHost, err := cmd.Flags().GetString("buildkit-host") @@ -206,8 +268,16 @@ func buildAction(cmd *cobra.Command, args []string) error { } defer cancel() - if err := builder.Build(ctx, client, options); err != nil { - return err + return builder.Build(ctx, client, options) +} + +// canonicalizeAttest is from https://github.com/docker/buildx/blob/v0.12/util/buildflags/attests.go##L13-L21 +func canonicalizeAttest(attestType string, in string) string { + if in == "" { + return "" + } + if b, err := strconv.ParseBool(in); err == nil { + return fmt.Sprintf("type=%s,disabled=%t", attestType, !b) } - return nil + return fmt.Sprintf("type=%s,%s", attestType, in) } diff --git a/cmd/nerdctl/builder/builder_build_oci_layout_test.go b/cmd/nerdctl/builder/builder_build_oci_layout_test.go new file mode 100644 index 00000000000..a3fea9b5fa5 --- /dev/null +++ b/cmd/nerdctl/builder/builder_build_oci_layout_test.go @@ -0,0 +1,107 @@ +/* + 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 builder + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestBuildContextWithOCILayout(t *testing.T) { + nerdtest.Setup() + + var dockerBuilderArgs []string + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(test.Windows), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + if nerdtest.IsDocker() { + helpers.Anyhow("buildx", "stop", data.Identifier("-container")) + helpers.Anyhow("buildx", "rm", "--force", data.Identifier("-container")) + } + helpers.Anyhow("rmi", "-f", data.Identifier("-parent")) + helpers.Anyhow("rmi", "-f", data.Identifier("-child")) + }, + Setup: func(data test.Data, helpers test.Helpers) { + // Default docker driver does not support OCI exporter. + // Reference: https://docs.docker.com/build/exporters/oci-docker/ + if nerdtest.IsDocker() { + name := data.Identifier("-container") + helpers.Ensure("buildx", "create", "--name", name, "--driver=docker-container") + dockerBuilderArgs = []string{"buildx", "--builder", name} + } + + dockerfile := fmt.Sprintf(`FROM %s +LABEL layer=oci-layout-parent +CMD ["echo", "test-nerdctl-build-context-oci-layout-parent"]`, testutil.CommonImage) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + + tarPath := filepath.Join(buildCtx, "parent.tar") + dest := filepath.Join(buildCtx, "parent") + assert.NilError(helpers.T(), os.MkdirAll(dest, 0o700)) + helpers.Ensure("build", buildCtx, "--tag", data.Identifier("-parent")) + helpers.Ensure("image", "save", "--output", tarPath, data.Identifier("-parent")) + helpers.Custom("tar", "Cxf", dest, tarPath).Run(&test.Expected{}) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + dockerfile := `FROM parent +CMD ["echo", "test-nerdctl-build-context-oci-layout"]` + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + + var cmd test.TestableCommand + if nerdtest.IsDocker() { + cmd = helpers.Command(dockerBuilderArgs...) + } else { + cmd = helpers.Command() + } + cmd.WithArgs("build", buildCtx, fmt.Sprintf("--build-context=parent=oci-layout://%s", filepath.Join(buildCtx, "parent")), "--tag", data.Identifier("-child")) + if nerdtest.IsDocker() { + // Need to load the container image from the builder to be able to run it. + cmd.WithArgs("--load") + } + return cmd + }, + 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.Contains(helpers.Capture("run", "--rm", data.Identifier("-child")), "test-nerdctl-build-context-oci-layout"), info) + }, + } + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/builder/builder_build_test.go b/cmd/nerdctl/builder/builder_build_test.go new file mode 100644 index 00000000000..407804f904d --- /dev/null +++ b/cmd/nerdctl/builder/builder_build_test.go @@ -0,0 +1,988 @@ +/* + 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 builder + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestBuildBasics(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"]`, testutil.CommonImage) + err := os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", data.TempDir()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + SubTests: []*test.Case{ + { + Description: "Successfully build with 'tag first', 'buildctx second'", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", "-t", data.Identifier(), data.Get("buildCtx")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + { + Description: "Successfully build with 'buildctx first', 'tag second'", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + { + Description: "Successfully build with output docker, main tag still works", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "-t", data.Identifier(), "--output=type=docker,name="+data.Identifier("ignored")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + { + Description: "Successfully build with output docker, name cannot be used", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "-t", data.Identifier(), "--output=type=docker,name="+data.Identifier("ignored")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier("ignored")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(-1, nil, nil), + }, + }, + } + + testCase.Run(t) +} + +func TestCanBuildOnOtherPlatform(t *testing.T) { + nerdtest.Setup() + + requireEmulation := &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + candidateArch := "arm64" + if runtime.GOARCH == "arm64" { + candidateArch = "amd64" + } + can, err := platformutil.CanExecProbably("linux/" + candidateArch) + assert.NilError(helpers.T(), err) + + data.Set("OS", "linux") + data.Set("Architecture", candidateArch) + return can, "Current environment does not support emulation" + }, + } + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + requireEmulation, + ), + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +RUN echo hello > /hello +CMD ["echo", "nerdctl-build-test-string"]`, testutil.CommonImage) + err := os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", data.TempDir()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "--platform", fmt.Sprintf("%s/%s", data.Get("OS"), data.Get("Architecture")), "-t", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} + +// TestBuildBaseImage tests if an image can be built on the previously built image. +// This isn't currently supported by nerdctl with BuildKit OCI worker. +func TestBuildBaseImage(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("first")) + helpers.Anyhow("rmi", "-f", data.Identifier("second")) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +RUN echo hello > /hello +CMD ["echo", "nerdctl-build-test-string"]`, testutil.CommonImage) + err := os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", data.Identifier("first"), data.TempDir()) + + dockerfileSecond := fmt.Sprintf(`FROM %s +RUN echo hello2 > /hello2 +CMD ["cat", "/hello2"]`, data.Identifier("first")) + err = os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfileSecond), 0644) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", data.Identifier("second"), data.TempDir()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier("second")) + }, + Expected: test.Expects(0, nil, test.Equals("hello2\n")), + } + + testCase.Run(t) +} + +// TestBuildFromContainerd tests if an image can be built on an image pulled by nerdctl. +// This isn't currently supported by nerdctl with BuildKit OCI worker. +func TestBuildFromContainerd(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("first")) + helpers.Anyhow("rmi", "-f", data.Identifier("second")) + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("tag", testutil.CommonImage, data.Identifier("first")) + + dockerfile := fmt.Sprintf(`FROM %s +RUN echo hello2 > /hello2 +CMD ["cat", "/hello2"]`, data.Identifier("first")) + err := os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", data.Identifier("second"), data.TempDir()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier("second")) + }, + Expected: test.Expects(0, nil, test.Equals("hello2\n")), + } + + testCase.Run(t) +} + +func TestBuildFromStdin(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-stdin"]`, testutil.CommonImage) + cmd := helpers.Command("build", "-t", data.Identifier(), "-f", "-", ".") + cmd.WithStdin(strings.NewReader(dockerfile)) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Errors: []error{errors.New(data.Identifier())}, + } + }, + } + + testCase.Run(t) +} + +func TestBuildWithDockerfile(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-dockerfile"] + `, testutil.CommonImage) + buildCtx := filepath.Join(data.TempDir(), "test") + err := os.MkdirAll(buildCtx, 0755) + assert.NilError(helpers.T(), err) + err = os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + SubTests: []*test.Case{ + { + Description: "Dockerfile ..", + 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("build", "-t", data.Identifier(), "-f", "Dockerfile", "..") + cmd.WithCwd(data.Get("buildCtx")) + return cmd + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "Dockerfile .", + 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("build", "-t", data.Identifier(), "-f", "Dockerfile", ".") + cmd.WithCwd(data.Get("buildCtx")) + return cmd + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "../Dockerfile .", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("build", "-t", data.Identifier(), "-f", "../Dockerfile", ".") + cmd.WithCwd(data.Get("buildCtx")) + return cmd + }, + Expected: test.Expects(1, nil, nil), + }, + }, + } + + testCase.Run(t) +} + +func TestBuildLocal(t *testing.T) { + nerdtest.Setup() + + const testFileName = "nerdctl-build-test" + const testContent = "nerdctl" + + testCase := &test.Case{ + Require: nerdtest.Build, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM scratch +COPY %s /`, testFileName) + + err := os.WriteFile(filepath.Join(data.TempDir(), "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + + err = os.WriteFile(filepath.Join(data.TempDir(), testFileName), []byte(testContent), 0644) + assert.NilError(helpers.T(), err) + + data.Set("buildCtx", data.TempDir()) + }, + SubTests: []*test.Case{ + { + Description: "destination 1", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", "-o", fmt.Sprintf("type=local,dest=%s", data.TempDir()), data.Get("buildCtx")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + testFilePath := filepath.Join(data.TempDir(), testFileName) + _, err := os.Stat(testFilePath) + assert.NilError(helpers.T(), err, info) + dt, err := os.ReadFile(testFilePath) + assert.NilError(helpers.T(), err, info) + assert.Equal(helpers.T(), string(dt), testContent, info) + }, + } + }, + }, + { + Description: "destination 2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", "-o", data.TempDir(), data.Get("buildCtx")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + testFilePath := filepath.Join(data.TempDir(), testFileName) + _, err := os.Stat(testFilePath) + assert.NilError(helpers.T(), err, info) + dt, err := os.ReadFile(testFilePath) + assert.NilError(helpers.T(), err, info) + assert.Equal(helpers.T(), string(dt), testContent, info) + }, + } + }, + }, + }, + } + + testCase.Run(t) +} + +func TestBuildWithBuildArg(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +ARG TEST_STRING=1 +ENV TEST_STRING=$TEST_STRING +CMD echo $TEST_STRING + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + SubTests: []*test.Case{ + { + Description: "No args", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("1\n")), + }, + { + Description: "ArgValueOverridesDefault", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "TEST_STRING=2", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("2\n")), + }, + { + Description: "EmptyArgValueOverridesDefault", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "TEST_STRING=", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("\n")), + }, + { + Description: "UnsetArgKeyPreservesDefault", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "TEST_STRING", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("1\n")), + }, + { + Description: "EnvValueOverridesDefault", + Env: map[string]string{ + "TEST_STRING": "3", + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "TEST_STRING", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("3\n")), + }, + { + Description: "EmptyEnvValueOverridesDefault", + Env: map[string]string{ + "TEST_STRING": "", + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "TEST_STRING", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("\n")), + }, + }, + } + + testCase.Run(t) +} + +func TestBuildWithIIDFile(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx, "--iidfile", filepath.Join(data.TempDir(), "id.txt"), "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + imageID, err := os.ReadFile(filepath.Join(data.TempDir(), "id.txt")) + assert.NilError(helpers.T(), err) + return helpers.Command("run", "--rm", string(imageID)) + }, + + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + } + + testCase.Run(t) +} + +func TestBuildWithLabels(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +LABEL name=nerdctl-build-test-label + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx, "--label", "label=test", "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("inspect", data.Identifier(), "--format", "{{json .Config.Labels }}") + }, + + Expected: test.Expects(0, nil, test.Equals("{\"label\":\"test\",\"name\":\"nerdctl-build-test-label\"}\n")), + } + + testCase.Run(t) +} + +func TestBuildMultipleTags(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Data: test.WithData("i1", "image"). + Set("i2", "image2"). + Set("i3", "image3:hello"), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Get("i1")) + helpers.Anyhow("rmi", "-f", data.Get("i2")) + helpers.Anyhow("rmi", "-f", data.Get("i3")) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx, "-t", data.Get("i1"), "-t", data.Get("i2"), "-t", data.Get("i3")) + }, + SubTests: []*test.Case{ + { + Description: "i1", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get("i1")) + }, + + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + { + Description: "i2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get("i2")) + }, + + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + { + Description: "i3", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get("i3")) + }, + + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + }, + } + + testCase.Run(t) +} + +func TestBuildWithContainerfile(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Containerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx, "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + } + + testCase.Run(t) +} + +func TestBuildWithDockerFileAndContainerfile(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "dockerfile"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + dockerfile = fmt.Sprintf(`FROM %s +CMD ["echo", "containerfile"] + `, testutil.CommonImage) + err = os.WriteFile(filepath.Join(buildCtx, "Containerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx, "-t", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("dockerfile\n")), + } + + testCase.Run(t) +} + +func TestBuildNoTag(t *testing.T) { + nerdtest.Setup() + + // FIXME: this test should be rewritten and instead get the image id from the build, then query the image explicitly - instead of pruning / noparallel + testCase := &test.Case{ + NoParallel: true, + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("image", "prune", "--force", "--all") + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx) + }, + Command: test.Command("images"), + Expected: test.Expects(0, nil, test.Contains("")), + } + + testCase.Run(t) +} + +func TestBuildContextDockerImageAlias(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := `FROM myorg/myapp +CMD ["echo", "nerdctl-build-myorg/myapp"]` + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", "-t", data.Identifier(), data.Get("buildCtx"), fmt.Sprintf("--build-context=myorg/myapp=docker-image://%s", testutil.CommonImage)) + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} + +func TestBuildContextWithCopyFromDir(t *testing.T) { + nerdtest.Setup() + + content := "hello_from_dir_2" + filename := "hello.txt" + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dir2 := helpers.T().TempDir() + filePath := filepath.Join(dir2, filename) + err := os.WriteFile(filePath, []byte(content), 0o600) + assert.NilError(helpers.T(), err) + dockerfile := fmt.Sprintf(`FROM %s +COPY --from=dir2 /%s /hello_from_dir2.txt +RUN ["cat", "/hello_from_dir2.txt"]`, testutil.CommonImage, filename) + buildCtx := data.TempDir() + err = os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + data.Set("dir2", dir2) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", "-t", data.Identifier(), data.Get("buildCtx"), fmt.Sprintf("--build-context=dir2=%s", data.Get("dir2"))) + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} + +// TestBuildSourceDateEpoch tests that $SOURCE_DATE_EPOCH is propagated from the client env +// https://github.com/docker/buildx/pull/1482 +func TestBuildSourceDateEpoch(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +ARG SOURCE_DATE_EPOCH +RUN echo $SOURCE_DATE_EPOCH >/source-date-epoch +CMD ["cat", "/source-date-epoch"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + SubTests: []*test.Case{ + { + Description: "1111111111", + Env: map[string]string{ + "SOURCE_DATE_EPOCH": "1111111111", + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "-t", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("1111111111\n")), + }, + { + Description: "2222222222", + Env: map[string]string{ + "SOURCE_DATE_EPOCH": "1111111111", + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("build", data.Get("buildCtx"), "--build-arg", "SOURCE_DATE_EPOCH=2222222222", "-t", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier()) + }, + Expected: test.Expects(0, nil, test.Equals("2222222222\n")), + }, + }, + } + + testCase.Run(t) +} + +func TestBuildNetwork(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +RUN apk add --no-cache curl +RUN curl -I http://google.com + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + SubTests: []*test.Case{ + { + Description: "none", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "-t", data.Identifier(), "--no-cache", "--network", "none") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(1, nil, nil), + }, + { + Description: "empty", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "-t", data.Identifier(), "--no-cache", "--network", "") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "default", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "-t", data.Identifier(), "--no-cache", "--network", "default") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Expected: test.Expects(0, nil, nil), + }, + }, + } + + testCase.Run(t) +} + +func TestBuildAttestation(t *testing.T) { + nerdtest.Setup() + + const testSBOMFileName = "sbom.spdx.json" + const testProvenanceFileName = "provenance.json" + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + test.Not(nerdtest.Docker), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + if nerdtest.IsDocker() { + helpers.Anyhow("buildx", "rm", data.Identifier("builder")) + } + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + if nerdtest.IsDocker() { + helpers.Anyhow("buildx", "create", "--name", data.Identifier("builder"), "--bootstrap", "--use") + } + + dockerfile := fmt.Sprintf(`FROM %s`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + SubTests: []*test.Case{ + { + Description: "SBOM", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + outputSBOMDir := helpers.T().TempDir() + data.Set("outputSBOMFile", filepath.Join(outputSBOMDir, testSBOMFileName)) + + cmd := helpers.Command("build") + if nerdtest.IsDocker() { + cmd.WithArgs("--builder", data.Identifier("builder")) + } + cmd.WithArgs("--sbom=true", "-o", fmt.Sprintf("type=local,dest=%s", outputSBOMDir), data.Get("buildCtx")) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + _, err := os.Stat(data.Get("outputSBOMFile")) + assert.NilError(t, err, info) + }, + } + }, + }, + { + Description: "Provenance", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + outputProvenanceDir := data.TempDir() + data.Set("outputProvenanceFile", filepath.Join(outputProvenanceDir, testProvenanceFileName)) + + cmd := helpers.Command("build") + if nerdtest.IsDocker() { + cmd.WithArgs("--builder", data.Identifier("builder")) + } + cmd.WithArgs("--provenance=mode=min", "-o", fmt.Sprintf("type=local,dest=%s", outputProvenanceDir), data.Get("buildCtx")) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + _, err := os.Stat(data.Get("outputProvenanceFile")) + assert.NilError(t, err, info) + }, + } + }, + }, + { + Description: "Attestation", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + outputAttestationDir := data.TempDir() + data.Set("outputSBOMFile", filepath.Join(outputAttestationDir, testSBOMFileName)) + data.Set("outputProvenanceFile", filepath.Join(outputAttestationDir, testProvenanceFileName)) + + cmd := helpers.Command("build") + if nerdtest.IsDocker() { + cmd.WithArgs("--builder", data.Identifier("builder")) + } + cmd.WithArgs("--attest=type=provenance,mode=min", "--attest=type=sbom", "-o", fmt.Sprintf("type=local,dest=%s", outputAttestationDir), data.Get("buildCtx")) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + _, err := os.Stat(data.Get("outputSBOMFile")) + assert.NilError(t, err, info) + _, err = os.Stat(data.Get("outputProvenanceFile")) + assert.NilError(t, err, info) + }, + } + }, + }, + }, + } + + testCase.Run(t) +} + +func TestBuildAddHost(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +RUN ping -c 5 alpha +RUN ping -c 5 beta +`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "-t", data.Identifier(), "--add-host", "alpha:127.0.0.1", "--add-host", "beta:127.0.0.1") + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/builder/builder_builder_test.go b/cmd/nerdctl/builder/builder_builder_test.go new file mode 100644 index 00000000000..f1092680a40 --- /dev/null +++ b/cmd/nerdctl/builder/builder_builder_test.go @@ -0,0 +1,158 @@ +/* + 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 builder + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestBuilder(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + NoParallel: true, + Require: test.Require( + nerdtest.Build, + test.Not(test.Windows), + ), + SubTests: []*test.Case{ + { + Description: "PruneForce", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-test-builder-prune"]`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx) + }, + Command: test.Command("builder", "prune", "--force"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "PruneForceAll", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-test-builder-prune"]`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx) + }, + Command: test.Command("builder", "prune", "--force", "--all"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "Debug", + // `nerdctl builder debug` is currently incompatible with `docker buildx debug`. + Require: test.Require(test.Not(nerdtest.Docker)), + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-builder-debug-test-string"]`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + cmd := helpers.Command("builder", "debug", buildCtx) + cmd.WithStdin(bytes.NewReader([]byte("c\n"))) + return cmd + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "WithPull", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + // FIXME: this test should be rewritten to dynamically retrieve the ids, and use images + // available on all platforms + oldImage := testutil.BusyboxImage + oldImageSha := "7b3ccabffc97de872a30dfd234fd972a66d247c8cfc69b0550f276481852627c" + newImage := testutil.AlpineImage + newImageSha := "ec14c7992a97fc11425907e908340c6c3d6ff602f5f13d899e6b7027c9b4133a" + + helpers.Ensure("pull", "--quiet", oldImage) + helpers.Ensure("tag", oldImage, newImage) + + dockerfile := fmt.Sprintf(`FROM %s`, newImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + + data.Set("buildCtx", buildCtx) + data.Set("oldImageSha", oldImageSha) + data.Set("newImageSha", newImageSha) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", testutil.AlpineImage) + }, + SubTests: []*test.Case{ + { + Description: "pull false", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "--pull=false") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Errors: []error{errors.New(data.Get("oldImageSha"))}, + } + }, + }, + { + Description: "pull true", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "--pull=true") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Errors: []error{errors.New(data.Get("newImageSha"))}, + } + }, + }, + { + Description: "no pull", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Errors: []error{errors.New(data.Get("newImageSha"))}, + } + }, + }, + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/builder/builder_test.go b/cmd/nerdctl/builder/builder_test.go new file mode 100644 index 00000000000..fee2491cb9b --- /dev/null +++ b/cmd/nerdctl/builder/builder_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 builder + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/builder_build_test.go b/cmd/nerdctl/builder_build_test.go deleted file mode 100644 index b2133ecd6e7..00000000000 --- a/cmd/nerdctl/builder_build_test.go +++ /dev/null @@ -1,451 +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 main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestBuild(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("build", buildCtx, "-t", imageName).AssertOK() - ignoredImageNamed := imageName + "-" + "ignored" - outputOpt := fmt.Sprintf("--output=type=docker,name=%s", ignoredImageNamed) - base.Cmd("build", buildCtx, "-t", imageName, outputOpt).AssertOK() - - base.Cmd("run", "--rm", imageName).AssertOutExactly("nerdctl-build-test-string\n") - base.Cmd("run", "--rm", ignoredImageNamed).AssertFail() -} - -// TestBuildBaseImage tests if an image can be built on the previously built image. -// This isn't currently supported by nerdctl with BuildKit OCI worker. -func TestBuildBaseImage(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - imageName2 := imageName + "-2" - defer base.Cmd("rmi", imageName2).Run() - - dockerfile := fmt.Sprintf(`FROM %s -RUN echo hello > /hello -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("build", buildCtx, "-t", imageName).AssertOK() - - dockerfile2 := fmt.Sprintf(`FROM %s -RUN echo hello2 > /hello2 -CMD ["cat", "/hello2"] - `, imageName) - - buildCtx2, err := createBuildContext(dockerfile2) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx2) - - base.Cmd("build", "-t", imageName2, buildCtx2).AssertOK() - base.Cmd("build", buildCtx2, "-t", imageName2).AssertOK() - - base.Cmd("run", "--rm", imageName2).AssertOutExactly("hello2\n") -} - -// TestBuildFromContainerd tests if an image can be built on an image pulled by nerdctl. -// This isn't currently supported by nerdctl with BuildKit OCI worker. -func TestBuildFromContainerd(t *testing.T) { - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - imageName2 := imageName + "-2" - defer base.Cmd("rmi", imageName2).Run() - - // FIXME: BuildKit sometimes tries to use base image manifests of platforms that hasn't been - // pulled by `nerdctl pull`. This leads to "not found" error for the base image. - // To avoid this issue, images shared to BuildKit should always be pulled by manifest - // digest or `--all-platforms` needs to be added. - base.Cmd("pull", "--all-platforms", testutil.CommonImage).AssertOK() - base.Cmd("tag", testutil.CommonImage, imageName).AssertOK() - base.Cmd("rmi", testutil.CommonImage).AssertOK() - - dockerfile2 := fmt.Sprintf(`FROM %s -RUN echo hello2 > /hello2 -CMD ["cat", "/hello2"] - `, imageName) - - buildCtx2, err := createBuildContext(dockerfile2) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx2) - - base.Cmd("build", "-t", imageName2, buildCtx2).AssertOK() - base.Cmd("build", buildCtx2, "-t", imageName2).AssertOK() - - base.Cmd("run", "--rm", imageName2).AssertOutExactly("hello2\n") -} - -func TestBuildFromStdin(t *testing.T) { - t.Parallel() - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-stdin"] - `, testutil.CommonImage) - - base.Cmd("build", "-t", imageName, "-f", "-", ".").CmdOption(testutil.WithStdin(strings.NewReader(dockerfile))).AssertCombinedOutContains(imageName) -} - -func TestBuildWithDockerfile(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-dockerfile"] - `, testutil.CommonImage) - - buildCtx := filepath.Join(t.TempDir(), "test") - err := os.MkdirAll(buildCtx, 0755) - assert.NilError(t, err) - err = os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0644) - assert.NilError(t, err) - - pwd, err := os.Getwd() - assert.NilError(t, err) - err = os.Chdir(buildCtx) - assert.NilError(t, err) - defer os.Chdir(pwd) - - // hack os.Getwd return "(unreachable)" on rootless - t.Setenv("PWD", buildCtx) - - base.Cmd("build", "-t", imageName, "-f", "Dockerfile", "..").AssertOK() - base.Cmd("build", "-t", imageName, "-f", "Dockerfile", ".").AssertOK() - // fail err: no such file or directory - base.Cmd("build", "-t", imageName, "-f", "../Dockerfile", ".").AssertFail() -} - -func TestBuildLocal(t *testing.T) { - t.Parallel() - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - if testutil.GetTarget() == testutil.Docker { - base.Env = append(base.Env, "DOCKER_BUILDKIT=1") - } - defer base.Cmd("builder", "prune").Run() - const testFileName = "nerdctl-build-test" - const testContent = "nerdctl" - outputDir := t.TempDir() - - dockerfile := fmt.Sprintf(`FROM scratch -COPY %s /`, - testFileName) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - if err := os.WriteFile(filepath.Join(buildCtx, testFileName), []byte(testContent), 0644); err != nil { - t.Fatal(err) - } - - testFilePath := filepath.Join(outputDir, testFileName) - base.Cmd("build", "-o", fmt.Sprintf("type=local,dest=%s", outputDir), buildCtx).AssertOK() - if _, err := os.Stat(testFilePath); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(testFilePath) - assert.NilError(t, err) - assert.Equal(t, string(data), testContent) - - aliasOutputDir := t.TempDir() - testAliasFilePath := filepath.Join(aliasOutputDir, testFileName) - base.Cmd("build", "-o", aliasOutputDir, buildCtx).AssertOK() - if _, err := os.Stat(testAliasFilePath); err != nil { - t.Fatal(err) - } - data, err = os.ReadFile(testAliasFilePath) - assert.NilError(t, err) - assert.Equal(t, string(data), testContent) -} - -func createBuildContext(dockerfile string) (string, error) { - tmpDir, err := os.MkdirTemp("", "nerdctl-build-test") - if err != nil { - return "", err - } - if err = os.WriteFile(filepath.Join(tmpDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil { - return "", err - } - return tmpDir, nil -} - -func TestBuildWithBuildArg(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -ARG TEST_STRING=1 -ENV TEST_STRING=$TEST_STRING -CMD echo $TEST_STRING - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", buildCtx, "-t", imageName).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly("1\n") - - validCases := []struct { - name string - arg string - envValue string - envSet bool - expected string - }{ - {"ArgValueOverridesDefault", "TEST_STRING=2", "", false, "2\n"}, - {"EmptyArgValueOverridesDefault", "TEST_STRING=", "", false, "\n"}, - {"UnsetArgKeyPreservesDefault", "TEST_STRING", "", false, "1\n"}, - {"EnvValueOverridesDefault", "TEST_STRING", "3", true, "3\n"}, - {"EmptyEnvValueOverridesDefault", "TEST_STRING", "", true, "\n"}, - } - - for _, tc := range validCases { - t.Run(tc.name, func(t *testing.T) { - if tc.envSet { - err := os.Setenv("TEST_STRING", tc.envValue) - assert.NilError(t, err) - defer os.Unsetenv("TEST_STRING") - } - - base.Cmd("build", buildCtx, "-t", imageName, "--build-arg", tc.arg).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly(tc.expected) - }) - } - - t.Run("InvalidBuildArgCausesError", func(t *testing.T) { - base.Cmd("build", buildCtx, "-t", imageName, "--build-arg", "=TEST_STRING").AssertFail() - }) -} - -func TestBuildWithIIDFile(t *testing.T) { - t.Parallel() - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - fileName := filepath.Join(t.TempDir(), "id.txt") - - base.Cmd("build", "-t", imageName, buildCtx, "--iidfile", fileName).AssertOK() - base.Cmd("build", buildCtx, "-t", imageName, "--iidfile", fileName).AssertOK() - defer os.Remove(fileName) - - imageID, err := os.ReadFile(fileName) - assert.NilError(t, err) - - base.Cmd("run", "--rm", string(imageID)).AssertOutExactly("nerdctl-build-test-string\n") -} - -func TestBuildWithLabels(t *testing.T) { - t.Parallel() - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - - dockerfile := fmt.Sprintf(`FROM %s -LABEL name=nerdctl-build-test-label - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", imageName, buildCtx, "--label", "label=test").AssertOK() - defer base.Cmd("rmi", imageName).Run() - - base.Cmd("inspect", imageName, "--format", "{{json .Config.Labels }}").AssertOutExactly("{\"label\":\"test\",\"name\":\"nerdctl-build-test-label\"}\n") -} - -func TestBuildMultipleTags(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - img := testutil.Identifier(t) - imgWithNoTag, imgWithCustomTag := fmt.Sprintf("%s%d", img, 2), fmt.Sprintf("%s%d:hello", img, 3) - defer base.Cmd("rmi", img).Run() - defer base.Cmd("rmi", imgWithNoTag).Run() - defer base.Cmd("rmi", imgWithCustomTag).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", img, buildCtx).AssertOK() - base.Cmd("build", buildCtx, "-t", img, "-t", imgWithNoTag, "-t", imgWithCustomTag).AssertOK() - base.Cmd("run", "--rm", img).AssertOutExactly("nerdctl-build-test-string\n") - base.Cmd("run", "--rm", imgWithNoTag).AssertOutExactly("nerdctl-build-test-string\n") - base.Cmd("run", "--rm", imgWithCustomTag).AssertOutExactly("nerdctl-build-test-string\n") -} - -func TestBuildWithContainerfile(t *testing.T) { - testutil.RequiresBuild(t) - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - containerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx := t.TempDir() - - var err = os.WriteFile(filepath.Join(buildCtx, "Containerfile"), []byte(containerfile), 0644) - assert.NilError(t, err) - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly("nerdctl-build-test-string\n") -} - -func TestBuildWithDockerFileAndContainerfile(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "dockerfile"] - `, testutil.CommonImage) - - containerfile := fmt.Sprintf(`FROM %s - CMD ["echo", "containerfile"] - `, testutil.CommonImage) - - tmpDir := t.TempDir() - - var err = os.WriteFile(filepath.Join(tmpDir, "Dockerfile"), []byte(dockerfile), 0644) - assert.NilError(t, err) - - err = os.WriteFile(filepath.Join(tmpDir, "Containerfile"), []byte(containerfile), 0644) - assert.NilError(t, err) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly("dockerfile\n") -} - -func TestBuildNoTag(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").AssertOK() - base.Cmd("image", "prune", "--force", "--all").AssertOK() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-notag-string"] - `, testutil.CommonImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", buildCtx).AssertOK() - base.Cmd("images").AssertOutContains("") - base.Cmd("image", "prune", "--force", "--all").AssertOK() -} - -// TestBuildSourceDateEpoch tests that $SOURCE_DATE_EPOCH is propagated from the client env -// https://github.com/docker/buildx/pull/1482 -func TestBuildSourceDateEpoch(t *testing.T) { - testutil.RequiresBuild(t) - testutil.DockerIncompatible(t) // Needs buildx v0.10 (https://github.com/docker/buildx/pull/1489) - base := testutil.NewBase(t) - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).AssertOK() - - dockerfile := fmt.Sprintf(`FROM %s -ARG SOURCE_DATE_EPOCH -RUN echo $SOURCE_DATE_EPOCH >/source-date-epoch -CMD ["cat", "/source-date-epoch"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - const sourceDateEpochEnvStr = "1111111111" - t.Setenv("SOURCE_DATE_EPOCH", sourceDateEpochEnvStr) - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly(sourceDateEpochEnvStr + "\n") - - const sourceDateEpochArgStr = "2222222222" - base.Cmd("build", "-t", imageName, "--build-arg", "SOURCE_DATE_EPOCH="+sourceDateEpochArgStr, buildCtx).AssertOK() - base.Cmd("run", "--rm", imageName).AssertOutExactly(sourceDateEpochArgStr + "\n") -} diff --git a/cmd/nerdctl/completion.go b/cmd/nerdctl/completion/completion.go similarity index 66% rename from cmd/nerdctl/completion.go rename to cmd/nerdctl/completion/completion.go index aefc30b45da..7718c1bb063 100644 --- a/cmd/nerdctl/completion.go +++ b/cmd/nerdctl/completion/completion.go @@ -14,21 +14,27 @@ limitations under the License. */ -package main +package completion import ( "context" "time" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/netutil" "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + + "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/volume" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) -func shellCompleteImageNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +func ImageNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -50,8 +56,8 @@ func shellCompleteImageNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirec return candidates, cobra.ShellCompDirectiveNoFileComp } -func shellCompleteContainerNames(cmd *cobra.Command, filterFunc func(containerd.ProcessStatus) bool) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +func ContainerNames(cmd *cobra.Command, filterFunc func(containerd.ProcessStatus) bool) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -98,9 +104,9 @@ func shellCompleteContainerNames(cmd *cobra.Command, filterFunc func(containerd. return candidates, cobra.ShellCompDirectiveNoFileComp } -// shellCompleteNetworkNames includes {"bridge","host","none"} -func shellCompleteNetworkNames(cmd *cobra.Command, exclude []string) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +// NetworkNames includes {"bridge","host","none"} +func NetworkNames(cmd *cobra.Command, exclude []string) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -109,7 +115,7 @@ func shellCompleteNetworkNames(cmd *cobra.Command, exclude []string) ([]string, excludeMap[ex] = struct{}{} } - e, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath) + e, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath, netutil.WithNamespace(globalOptions.Namespace)) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -118,9 +124,13 @@ func shellCompleteNetworkNames(cmd *cobra.Command, exclude []string) ([]string, if err != nil { return nil, cobra.ShellCompDirectiveError } - for netName := range netConfigs { + for netName, network := range netConfigs { if _, ok := excludeMap[netName]; !ok { candidates = append(candidates, netName) + if network.NerdctlID != nil { + candidates = append(candidates, *network.NerdctlID) + candidates = append(candidates, (*network.NerdctlID)[0:12]) + } } } for _, s := range []string{"host", "none"} { @@ -131,8 +141,8 @@ func shellCompleteNetworkNames(cmd *cobra.Command, exclude []string) ([]string, return candidates, cobra.ShellCompDirectiveNoFileComp } -func shellCompleteVolumeNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +func VolumeNames(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -147,7 +157,7 @@ func shellCompleteVolumeNames(cmd *cobra.Command) ([]string, cobra.ShellCompDire return candidates, cobra.ShellCompDirectiveNoFileComp } -func shellCompletePlatforms(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { +func Platforms(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { candidates := []string{ "amd64", "arm64", @@ -160,3 +170,12 @@ func shellCompletePlatforms(cmd *cobra.Command, args []string, toComplete string } return candidates, cobra.ShellCompDirectiveNoFileComp } + +func getVolumes(cmd *cobra.Command, globalOptions types.GlobalCommandOptions) (map[string]native.Volume, error) { + volumeSize, err := cmd.Flags().GetBool("size") + if err != nil { + // The `nerdctl volume rm` does not have the flag `size`, so set it to false as the default value. + volumeSize = false + } + return volume.Volumes(globalOptions.Namespace, globalOptions.DataRoot, globalOptions.Address, volumeSize, nil) +} diff --git a/cmd/nerdctl/completion/completion_freebsd.go b/cmd/nerdctl/completion/completion_freebsd.go new file mode 100644 index 00000000000..465671cfc24 --- /dev/null +++ b/cmd/nerdctl/completion/completion_freebsd.go @@ -0,0 +1,23 @@ +/* + 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 completion + +import "github.com/spf13/cobra" + +func CgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/nerdctl/completion/completion_linux.go b/cmd/nerdctl/completion/completion_linux.go new file mode 100644 index 00000000000..38a992c19b5 --- /dev/null +++ b/cmd/nerdctl/completion/completion_linux.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 completion + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) + +func ApparmorProfiles(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { + profiles, err := apparmorutil.Profiles() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var names []string // nolint: prealloc + for _, f := range profiles { + names = append(names, f.Name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +func CgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + candidates := []string{"cgroupfs"} + if ncdefaults.IsSystemdAvailable() { + candidates = append(candidates, "systemd") + } + if rootlessutil.IsRootless() { + candidates = append(candidates, "none") + } + return candidates, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/nerdctl/completion/completion_test.go b/cmd/nerdctl/completion/completion_test.go new file mode 100644 index 00000000000..84ba1377153 --- /dev/null +++ b/cmd/nerdctl/completion/completion_test.go @@ -0,0 +1,216 @@ +/* + 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 completion + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} + +func TestCompletion(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("network", "create", identifier) + helpers.Ensure("volume", "create", identifier) + data.Set("identifier", identifier) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("network", "rm", identifier) + helpers.Anyhow("volume", "rm", identifier) + }, + SubTests: []*test.Case{ + { + Description: "--cgroup-manager", + Require: test.Not(test.Windows), + Command: test.Command("__complete", "--cgroup-manager", ""), + Expected: test.Expects(0, nil, test.Contains("cgroupfs\n")), + }, + { + Description: "--snapshotter", + Require: test.Not(test.Windows), + Command: test.Command("__complete", "--snapshotter", ""), + Expected: test.Expects(0, nil, test.Contains("native\n")), + }, + { + Description: "empty", + Command: test.Command("__complete", ""), + Expected: test.Expects(0, nil, test.Contains("run\t")), + }, + { + Description: "build --network", + Command: test.Command("__complete", "build", "--network", ""), + Expected: test.Expects(0, nil, test.Contains("default\n")), + }, + { + Description: "run -", + Command: test.Command("__complete", "run", "-"), + Expected: test.Expects(0, nil, test.Contains("--network\t")), + }, + { + Description: "run --n", + Command: test.Command("__complete", "run", "--n"), + Expected: test.Expects(0, nil, test.Contains("--network\t")), + }, + { + Description: "run --ne", + Command: test.Command("__complete", "run", "--ne"), + Expected: test.Expects(0, nil, test.Contains("--network\t")), + }, + { + Description: "run --net", + Command: test.Command("__complete", "run", "--net", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains("host\n"), + test.Contains(data.Get("identifier")+"\n"), + ), + } + }, + }, + { + Description: "run -it --net", + Command: test.Command("__complete", "run", "-it", "--net", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains("host\n"), + test.Contains(data.Get("identifier")+"\n"), + ), + } + }, + }, + { + Description: "run -ti --rm --net", + Command: test.Command("__complete", "run", "-it", "--rm", "--net", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains("host\n"), + test.Contains(data.Get("identifier")+"\n"), + ), + } + }, + }, + { + Description: "run --restart", + Command: test.Command("__complete", "run", "--restart", ""), + Expected: test.Expects(0, nil, test.Contains("always\n")), + }, + { + Description: "network --rm", + Command: test.Command("__complete", "network", "rm", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain("host\n"), + test.Contains(data.Get("identifier")+"\n"), + ), + } + }, + }, + { + Description: "run --cap-add", + Require: test.Not(test.Windows), + Command: test.Command("__complete", "run", "--cap-add", ""), + Expected: test.Expects(0, nil, test.All( + test.Contains("sys_admin\n"), + test.DoesNotContain("CAP_SYS_ADMIN\n"), + )), + }, + { + Description: "volume inspect", + Command: test.Command("__complete", "volume", "inspect", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("identifier") + "\n"), + } + }, + }, + { + Description: "volume rm", + Command: test.Command("__complete", "volume", "rm", ""), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("identifier") + "\n"), + } + }, + }, + { + Description: "no namespace --cgroup-manager", + Require: test.Not(test.Windows), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("nerdctl", "__complete", "--cgroup-manager", "") + }, + Expected: test.Expects(0, nil, test.Contains("cgroupfs\n")), + }, + { + Description: "no namespace empty", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("nerdctl", "__complete", "") + }, + Expected: test.Expects(0, nil, test.Contains("run\t")), + }, + { + Description: "namespace space empty", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // mind {"--namespace=nerdctl-test"} vs {"--namespace", "nerdctl-test"} + return helpers.Custom("nerdctl", "__complete", "--namespace", string(helpers.Read(nerdtest.Namespace)), "") + }, + Expected: test.Expects(0, nil, test.Contains("run\t")), + }, + { + Description: "run -i", + Command: test.Command("__complete", "run", "-i", ""), + Expected: test.Expects(0, nil, test.Contains(testutil.CommonImage)), + }, + { + Description: "run -it", + Command: test.Command("__complete", "run", "-it", ""), + Expected: test.Expects(0, nil, test.Contains(testutil.CommonImage)), + }, + { + Description: "run -it --rm", + Command: test.Command("__complete", "run", "-it", "--rm", ""), + Expected: test.Expects(0, nil, test.Contains(testutil.CommonImage)), + }, + { + Description: "namespace run -i", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // mind {"--namespace=nerdctl-test"} vs {"--namespace", "nerdctl-test"} + return helpers.Custom("nerdctl", "__complete", "--namespace", string(helpers.Read(nerdtest.Namespace)), "run", "-i", "") + }, + Expected: test.Expects(0, nil, test.Contains(testutil.CommonImage+"\n")), + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/main_unix.go b/cmd/nerdctl/completion/completion_unix.go similarity index 65% rename from cmd/nerdctl/main_unix.go rename to cmd/nerdctl/completion/completion_unix.go index ce8e8f9656a..af0b8698ce2 100644 --- a/cmd/nerdctl/main_unix.go +++ b/cmd/nerdctl/completion/completion_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -16,18 +16,30 @@ limitations under the License. */ -package main +package completion import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + "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/infoutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) -func shellCompleteNamespaceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +func NetworkDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + candidates := []string{"bridge", "macvlan", "ipvlan"} + return candidates, cobra.ShellCompDirectiveNoFileComp +} + +func IPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"default", "host-local", "dhcp"}, cobra.ShellCompDirectiveNoFileComp +} + +func NamespaceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -35,9 +47,7 @@ func shellCompleteNamespaceNames(cmd *cobra.Command, args []string, toComplete s _ = rootlessutil.ParentMain(globalOptions.HostGatewayIP) return nil, cobra.ShellCompDirectiveNoFileComp } - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) if err != nil { return nil, cobra.ShellCompDirectiveError @@ -46,7 +56,7 @@ func shellCompleteNamespaceNames(cmd *cobra.Command, args []string, toComplete s nsService := client.NamespaceService() nsList, err := nsService.List(ctx) if err != nil { - logrus.Warn(err) + log.L.Warn(err) return nil, cobra.ShellCompDirectiveError } var candidates []string @@ -54,8 +64,8 @@ func shellCompleteNamespaceNames(cmd *cobra.Command, args []string, toComplete s return candidates, cobra.ShellCompDirectiveNoFileComp } -func shellCompleteSnapshotterNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - globalOptions, err := processRootCmdFlags(cmd) +func SnapshotterNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return nil, cobra.ShellCompDirectiveError } diff --git a/cmd/nerdctl/completion/completion_windows.go b/cmd/nerdctl/completion/completion_windows.go new file mode 100644 index 00000000000..020e0594926 --- /dev/null +++ b/cmd/nerdctl/completion/completion_windows.go @@ -0,0 +1,40 @@ +/* + 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 completion + +import "github.com/spf13/cobra" + +func NamespaceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func SnapshotterNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func CgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func NetworkDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + candidates := []string{"nat"} + return candidates, cobra.ShellCompDirectiveNoFileComp +} + +func IPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"default"}, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/nerdctl/completion_linux_test.go b/cmd/nerdctl/completion_linux_test.go deleted file mode 100644 index ccff901371e..00000000000 --- a/cmd/nerdctl/completion_linux_test.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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestCompletion(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - const gsc = "__complete" - // cmd is executed with base.Args={"--namespace=nerdctl-test"} - base.Cmd(gsc, "--cgroup-manager", "").AssertOutContains("cgroupfs\n") - base.Cmd(gsc, "--snapshotter", "").AssertOutContains("native\n") - base.Cmd(gsc, "").AssertOutContains("run\t") - base.Cmd(gsc, "run", "-").AssertOutContains("--network\t") - base.Cmd(gsc, "run", "--n").AssertOutContains("--network\t") - base.Cmd(gsc, "run", "--ne").AssertOutContains("--network\t") - base.Cmd(gsc, "run", "--net", "").AssertOutContains("host\n") - base.Cmd(gsc, "run", "-it", "--net", "").AssertOutContains("host\n") - base.Cmd(gsc, "run", "-it", "--rm", "--net", "").AssertOutContains("host\n") - base.Cmd(gsc, "run", "--restart", "").AssertOutContains("always\n") - base.Cmd(gsc, "network", "rm", "").AssertNoOut("host\n") // host is unremovable - base.Cmd(gsc, "run", "--cap-add", "").AssertOutContains("sys_admin\n") - base.Cmd(gsc, "run", "--cap-add", "").AssertNoOut("CAP_SYS_ADMIN\n") // invalid form - - // Tests with an image - base.Cmd("pull", testutil.AlpineImage).AssertOK() - base.Cmd(gsc, "run", "-i", "").AssertOutContains(testutil.AlpineImage) - base.Cmd(gsc, "run", "-it", "").AssertOutContains(testutil.AlpineImage) - base.Cmd(gsc, "run", "-it", "--rm", "").AssertOutContains(testutil.AlpineImage) - - // Tests with an network - testNetworkName := "nerdctl-test-completion" - defer base.Cmd("network", "rm", testNetworkName).Run() - base.Cmd("network", "create", testNetworkName).AssertOK() - base.Cmd(gsc, "network", "rm", "").AssertOutContains(testNetworkName) - base.Cmd(gsc, "run", "--net", "").AssertOutContains(testNetworkName) - - // Tests with raw base (without Args={"--namespace=nerdctl-test"}) - rawBase := testutil.NewBase(t) - rawBase.Args = nil // unset "--namespace=nerdctl-test" - rawBase.Cmd(gsc, "--cgroup-manager", "").AssertOutContains("cgroupfs\n") - rawBase.Cmd(gsc, "").AssertOutContains("run\t") - // mind {"--namespace=nerdctl-test"} vs {"--namespace", "nerdctl-test"} - rawBase.Cmd(gsc, "--namespace", testutil.Namespace, "").AssertOutContains("run\t") - rawBase.Cmd(gsc, "--namespace", testutil.Namespace, "run", "-i", "").AssertOutContains(testutil.AlpineImage) -} diff --git a/cmd/nerdctl/compose.go b/cmd/nerdctl/compose/compose.go similarity index 88% rename from cmd/nerdctl/compose.go rename to cmd/nerdctl/compose/compose.go index d806c57cb27..e85d31755f4 100644 --- a/cmd/nerdctl/compose.go +++ b/cmd/nerdctl/compose/compose.go @@ -14,24 +14,26 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/composer" ) -func newComposeCommand() *cobra.Command { +func NewComposeCommand() *cobra.Command { var composeCommand = &cobra.Command{ Use: "compose [flags] COMMAND", Short: "Compose", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, // required for global short hands like -f } // `-f` is a nonPersistentAlias, as it conflicts with `nerdctl compose logs --follow` - AddPersistentStringArrayFlag(composeCommand, "file", nil, []string{"f"}, nil, "", "Specify an alternate compose file") + helpers.AddPersistentStringArrayFlag(composeCommand, "file", nil, []string{"f"}, nil, "", "Specify an alternate compose file") composeCommand.PersistentFlags().String("project-directory", "", "Specify an alternate working directory") composeCommand.PersistentFlags().StringP("project-name", "p", "", "Specify an alternate project name") composeCommand.PersistentFlags().String("env-file", "", "Specify an alternate environment file") @@ -42,6 +44,7 @@ func newComposeCommand() *cobra.Command { newComposeUpCommand(), newComposeLogsCommand(), newComposeConfigCommand(), + newComposeCopyCommand(), newComposeBuildCommand(), newComposeExecCommand(), newComposeImagesCommand(), @@ -67,7 +70,7 @@ func newComposeCommand() *cobra.Command { } func getComposeOptions(cmd *cobra.Command, debugFull, experimental bool) (composer.Options, error) { - nerdctlCmd, nerdctlArgs := globalFlags(cmd) + nerdctlCmd, nerdctlArgs := helpers.GlobalFlags(cmd) projectDirectory, err := cmd.Flags().GetString("project-directory") if err != nil { return composer.Options{}, err diff --git a/cmd/nerdctl/compose_build.go b/cmd/nerdctl/compose/compose_build.go similarity index 88% rename from cmd/nerdctl/compose_build.go rename to cmd/nerdctl/compose/compose_build.go index be59dbad7b7..fbd72ff0f39 100644 --- a/cmd/nerdctl/compose_build.go +++ b/cmd/nerdctl/compose/compose_build.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeBuildCommand() *cobra.Command { @@ -39,7 +41,7 @@ func newComposeBuildCommand() *cobra.Command { } func composeBuildAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_build_linux_test.go b/cmd/nerdctl/compose/compose_build_linux_test.go similarity index 95% rename from cmd/nerdctl/compose_build_linux_test.go rename to cmd/nerdctl/compose/compose_build_linux_test.go index e80bdd7895f..80cc04d4c35 100644 --- a/cmd/nerdctl/compose_build_linux_test.go +++ b/cmd/nerdctl/compose/compose_build_linux_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeBuild(t *testing.T) { @@ -46,8 +46,8 @@ services: dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() diff --git a/cmd/nerdctl/compose_config.go b/cmd/nerdctl/compose/compose_config.go similarity index 90% rename from cmd/nerdctl/compose_config.go rename to cmd/nerdctl/compose/compose_config.go index 90c506ce092..25a3305779f 100644 --- a/cmd/nerdctl/compose_config.go +++ b/cmd/nerdctl/compose/compose_config.go @@ -14,15 +14,17 @@ limitations under the License. */ -package main +package compose import ( "fmt" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeConfigCommand() *cobra.Command { @@ -44,7 +46,7 @@ func newComposeConfigCommand() *cobra.Command { } func composeConfigAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_config_test.go b/cmd/nerdctl/compose/compose_config_test.go similarity index 77% rename from cmd/nerdctl/compose_config_test.go rename to cmd/nerdctl/compose/compose_config_test.go index 4f519a46c99..18dd728da5a 100644 --- a/cmd/nerdctl/compose_config_test.go +++ b/cmd/nerdctl/compose/compose_config_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "fmt" @@ -22,8 +22,9 @@ import ( "path/filepath" "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeConfig(t *testing.T) { @@ -68,7 +69,11 @@ services: comp := testutil.NewComposeDir(t, fmt.Sprintf(dockerComposeYAML, "3.13")) defer comp.CleanUp() - base.ComposeCmd("-f", comp.YAMLFullPath(), "config", "--hash=*").AssertOutContains("hello1") + // `--hash=*` is broken in Docker Compose v2.23.0: https://github.com/docker/compose/issues/11145 + if base.Target == testutil.Nerdctl { + base.ComposeCmd("-f", comp.YAMLFullPath(), "config", "--hash=*").AssertOutContains("hello1") + } + hash := base.ComposeCmd("-f", comp.YAMLFullPath(), "config", "--hash=hello1").Out() newComp := testutil.NewComposeDir(t, fmt.Sprintf(dockerComposeYAML, "3.14")) @@ -124,9 +129,30 @@ services: image: alpine:3.14 `) - base.Env = append(os.Environ(), "COMPOSE_FILE="+comp.YAMLFullPath()+","+filepath.Join(comp.Dir(), "docker-compose.test.yml"), "COMPOSE_PATH_SEPARATOR=,") + base.Env = append(base.Env, "COMPOSE_FILE="+comp.YAMLFullPath()+","+filepath.Join(comp.Dir(), "docker-compose.test.yml"), "COMPOSE_PATH_SEPARATOR=,") base.ComposeCmd("config").AssertOutContains("alpine:3.14") base.ComposeCmd("--project-directory", comp.Dir(), "config", "--services").AssertOutContainsAll("hello1\n", "hello2\n") base.ComposeCmd("--project-directory", comp.Dir(), "config").AssertOutContains("alpine:3.14") } + +func TestComposeConfigWithEnvFile(t *testing.T) { + base := testutil.NewBase(t) + + const dockerComposeYAML = ` +services: + hello: + image: ${image} +` + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + envFile := filepath.Join(comp.Dir(), "env") + const envFileContent = ` +image: hello-world +` + assert.NilError(t, os.WriteFile(envFile, []byte(envFileContent), 0644)) + + base.ComposeCmd("-f", comp.YAMLFullPath(), "--env-file", envFile, "config").AssertOutContains("image: hello-world") +} diff --git a/cmd/nerdctl/compose/compose_cp.go b/cmd/nerdctl/compose/compose_cp.go new file mode 100644 index 00000000000..a35078bc405 --- /dev/null +++ b/cmd/nerdctl/compose/compose_cp.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 compose + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) + +func newComposeCopyCommand() *cobra.Command { + usage := `cp [OPTIONS] SERVICE:SRC_PATH DEST_PATH|- + nerdctl compose cp [OPTIONS] SRC_PATH|- SERVICE:DEST_PATH` + var composeCpCommand = &cobra.Command{ + Use: usage, + Short: "Copy files/folders between a service container and the local filesystem", + Args: cobra.ExactArgs(2), + RunE: composeCopyAction, + SilenceUsage: true, + SilenceErrors: true, + } + composeCpCommand.Flags().Bool("dry-run", false, "Execute command in dry run mode") + composeCpCommand.Flags().BoolP("follow-link", "L", false, "Always follow symbol link in SRC_PATH") + composeCpCommand.Flags().Int("index", 0, "index of the container if service has multiple replicas") + return composeCpCommand +} + +func composeCopyAction(cmd *cobra.Command, args []string) error { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + source := args[0] + if source == "" { + return errors.New("source can not be empty") + } + destination := args[1] + if destination == "" { + return errors.New("destination can not be empty") + } + + dryRun, err := cmd.Flags().GetBool("dry-run") + if err != nil { + return err + } + followLink, err := cmd.Flags().GetBool("follow-link") + if err != nil { + return err + } + index, err := cmd.Flags().GetInt("index") + if err != nil { + return err + } + address := globalOptions.Address + // rootless cp runs in the host namespaces, so the address is different + if rootlessutil.IsRootless() { + address, err = rootlessutil.RootlessContainredSockAddress() + if err != nil { + return err + } + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, address) + if err != nil { + return err + } + defer cancel() + options, err := getComposeOptions(cmd, globalOptions.DebugFull, globalOptions.Experimental) + if err != nil { + return err + } + c, err := compose.New(client, globalOptions, options, cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err != nil { + return err + } + + co := composer.CopyOptions{ + Source: source, + Destination: destination, + Index: index, + FollowLink: followLink, + DryRun: dryRun, + } + return c.Copy(ctx, co) + +} diff --git a/cmd/nerdctl/compose/compose_cp_linux_test.go b/cmd/nerdctl/compose/compose_cp_linux_test.go new file mode 100644 index 00000000000..605210d8946 --- /dev/null +++ b/cmd/nerdctl/compose/compose_cp_linux_test.go @@ -0,0 +1,69 @@ +/* + 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 compose + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestComposeCopy(t *testing.T) { + base := testutil.NewBase(t) + + var dockerComposeYAML = fmt.Sprintf(` +version: '3.1' + +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) + + base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + + // gernetate test file + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "test-file") + srcFileContent := []byte("test-file-content") + err := os.WriteFile(srcFile, srcFileContent, 0o644) + assert.NilError(t, err) + + // test copy to service + destPath := "/dest-no-exist-no-slash" + base.ComposeCmd("-f", comp.YAMLFullPath(), "cp", srcFile, "svc0:"+destPath).AssertOK() + + // test copy from service + destFile := filepath.Join(srcDir, "test-file2") + base.ComposeCmd("-f", comp.YAMLFullPath(), "cp", "svc0:"+destPath, destFile).AssertOK() + + destFileContent, err := os.ReadFile(destFile) + assert.NilError(t, err) + assert.DeepEqual(t, srcFileContent, destFileContent) + +} diff --git a/cmd/nerdctl/compose_create.go b/cmd/nerdctl/compose/compose_create.go similarity index 91% rename from cmd/nerdctl/compose_create.go rename to cmd/nerdctl/compose/compose_create.go index 7b60ed8dc98..9621398ae49 100644 --- a/cmd/nerdctl/compose_create.go +++ b/cmd/nerdctl/compose/compose_create.go @@ -14,15 +14,17 @@ limitations under the License. */ -package main +package compose import ( "errors" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeCreateCommand() *cobra.Command { @@ -42,7 +44,7 @@ func newComposeCreateCommand() *cobra.Command { } func composeCreateAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -63,7 +65,7 @@ func composeCreateAction(cmd *cobra.Command, args []string) error { } noRecreate, err := cmd.Flags().GetBool("no-recreate") if err != nil { - return nil + return err } if forceRecreate && noRecreate { return errors.New("flag --force-recreate and --no-recreate cannot be specified together") diff --git a/cmd/nerdctl/compose_create_linux_test.go b/cmd/nerdctl/compose/compose_create_linux_test.go similarity index 77% rename from cmd/nerdctl/compose_create_linux_test.go rename to cmd/nerdctl/compose/compose_create_linux_test.go index 7935d437c36..43f2dc40067 100644 --- a/cmd/nerdctl/compose_create_linux_test.go +++ b/cmd/nerdctl/compose/compose_create_linux_test.go @@ -14,20 +14,16 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeCreate(t *testing.T) { - // docker-compose v1 depecreated this command - // docker-compose v2 reimplemented this command - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -46,16 +42,12 @@ services: // 1.1 `compose create` should create service container (in `created` status) base.ComposeCmd("-f", comp.YAMLFullPath(), "create").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") // 1.2 created container can be started by `compose start` base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertOK() } func TestComposeCreateDependency(t *testing.T) { - // docker-compose v1 depecreated this command - // docker-compose v2 reimplemented this command - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -78,14 +70,11 @@ services: // `compose create` should create containers for both services and their dependencies base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1", "-a").AssertOutContainsAny("Created", "created") } func TestComposeCreatePull(t *testing.T) { - // docker-compose v1 depecreated this command - // docker-compose v2 reimplemented this command - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` @@ -111,14 +100,10 @@ services: 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").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") } func TestComposeCreateBuild(t *testing.T) { - // docker-compose v1 depecreated this command - // docker-compose v2 reimplemented this command - testutil.DockerIncompatible(t) - const imageSvc0 = "composebuild_svc0" dockerComposeYAML := fmt.Sprintf(` @@ -131,8 +116,8 @@ services: dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() @@ -148,5 +133,5 @@ services: // `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").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") } diff --git a/cmd/nerdctl/compose_down.go b/cmd/nerdctl/compose/compose_down.go similarity index 88% rename from cmd/nerdctl/compose_down.go rename to cmd/nerdctl/compose/compose_down.go index 236f96fd61a..30552d18947 100644 --- a/cmd/nerdctl/compose_down.go +++ b/cmd/nerdctl/compose/compose_down.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeDownCommand() *cobra.Command { @@ -38,7 +40,7 @@ func newComposeDownCommand() *cobra.Command { } func composeDownAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_down_linux_test.go b/cmd/nerdctl/compose/compose_down_linux_test.go similarity index 89% rename from cmd/nerdctl/compose_down_linux_test.go rename to cmd/nerdctl/compose/compose_down_linux_test.go index 5b3943f3782..b995631d6b6 100644 --- a/cmd/nerdctl/compose_down_linux_test.go +++ b/cmd/nerdctl/compose/compose_down_linux_test.go @@ -14,23 +14,20 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" "time" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeDownRemoveUsedNetwork(t *testing.T) { base := testutil.NewBase(t) - // The error output is different with docker - testutil.DockerIncompatible(t) - var ( dockerComposeYAMLOrphan = fmt.Sprintf(` version: '3.1' @@ -60,7 +57,7 @@ services: 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("is in use") + base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "down", "-v").AssertCombinedOutContains("in use") } @@ -99,5 +96,5 @@ services: 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").AssertOutNotContains(orphanContainer) + base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps", "-a").AssertOutNotContains(orphanContainer) } diff --git a/cmd/nerdctl/compose_exec.go b/cmd/nerdctl/compose/compose_exec.go similarity index 78% rename from cmd/nerdctl/compose_exec.go rename to cmd/nerdctl/compose/compose_exec.go index 06919b6309e..79d5da5bdcb 100644 --- a/cmd/nerdctl/compose_exec.go +++ b/cmd/nerdctl/compose/compose_exec.go @@ -14,15 +14,19 @@ limitations under the License. */ -package main +package compose import ( "errors" + "os" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" + "github.com/moby/term" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeExecCommand() *cobra.Command { @@ -36,22 +40,29 @@ func newComposeExecCommand() *cobra.Command { } composeExecCommand.Flags().SetInterspersed(false) - composeExecCommand.Flags().BoolP("tty", "t", true, "Allocate a pseudo-TTY") - composeExecCommand.Flags().BoolP("interactive", "i", true, "Keep STDIN open even if not attached") + _, isTerminal := term.GetFdInfo(os.Stdout) + composeExecCommand.Flags().BoolP("no-TTY", "T", !isTerminal, "Disable pseudo-TTY allocation. By default nerdctl compose exec allocates a TTY.") composeExecCommand.Flags().BoolP("detach", "d", false, "Detached mode: Run containers in the background") composeExecCommand.Flags().StringP("workdir", "w", "", "Working directory inside the container") // env needs to be StringArray, not StringSlice, to prevent "FOO=foo1,foo2" from being split to {"FOO=foo1", "foo2"} composeExecCommand.Flags().StringArrayP("env", "e", nil, "Set environment variables") - // TODO: no-TTY flag composeExecCommand.Flags().Bool("privileged", false, "Give extended privileges to the command") composeExecCommand.Flags().StringP("user", "u", "", "Username or UID (format: [:])") composeExecCommand.Flags().Int("index", 1, "index of the container if the service has multiple instances.") + composeExecCommand.Flags().BoolP("interactive", "i", true, "Keep STDIN open even if not attached") + composeExecCommand.Flags().MarkHidden("interactive") + // The -t does not has effect to keep the compatibility with docker. + // The proposal of -t is to keep "muscle memory" with compose v1: https://github.com/docker/compose/issues/9207 + // FYI: https://github.com/docker/compose/blob/v2.23.1/cmd/compose/exec.go#L77 + composeExecCommand.Flags().BoolP("tty", "t", true, "Allocate a pseudo-TTY") + composeExecCommand.Flags().MarkHidden("tty") + return composeExecCommand } func composeExecAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -59,7 +70,7 @@ func composeExecAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - tty, err := cmd.Flags().GetBool("tty") + noTty, err := cmd.Flags().GetBool("no-TTY") if err != nil { return err } @@ -96,8 +107,8 @@ func composeExecAction(cmd *cobra.Command, args []string) error { return errors.New("currently flag -i and -d cannot be specified together (FIXME)") } // https://github.com/containerd/nerdctl/blob/v1.0.0/cmd/nerdctl/exec.go#L122 - if tty && detach { - return errors.New("currently flag -t and -d cannot be specified together (FIXME)") + if !noTty && detach { + return errors.New("currently flag -d should be specified with --no-TTY (FIXME)") } client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) @@ -119,7 +130,7 @@ func composeExecAction(cmd *cobra.Command, args []string) error { Index: index, Interactive: interactive, - Tty: tty, + Tty: !noTty, Detach: detach, WorkDir: workdir, Env: env, diff --git a/cmd/nerdctl/compose_exec_linux_test.go b/cmd/nerdctl/compose/compose_exec_linux_test.go similarity index 66% rename from cmd/nerdctl/compose_exec_linux_test.go rename to cmd/nerdctl/compose/compose_exec_linux_test.go index 28d92d76efa..24e0b3a51de 100644 --- a/cmd/nerdctl/compose_exec_linux_test.go +++ b/cmd/nerdctl/compose/compose_exec_linux_test.go @@ -14,23 +14,21 @@ limitations under the License. */ -package main +package compose import ( "errors" "fmt" - "os" - "runtime" + "net" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeExec(t *testing.T) { - // disabling `-it` in `compose exec` is only supported in compose v2. - // Currently CI is using compose v1. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -53,16 +51,13 @@ services: defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() // test basic functionality and `--workdir` flag - base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "-t=false", "svc0", "echo", "success").AssertOutExactly("success\n") - base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "-t=false", "--workdir", "/tmp", "svc0", "pwd").AssertOutExactly("/tmp\n") + base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY", "svc0", "echo", "success").AssertOutExactly("success\n") + base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY", "--workdir", "/tmp", "svc0", "pwd").AssertOutExactly("/tmp\n") // cannot `exec` on non-running service base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "svc1", "echo", "success").AssertFail() } func TestComposeExecWithEnv(t *testing.T) { - // disabling `-it` in `compose exec` is only supported in compose v2. - // Currently CI is using compose v1. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -82,8 +77,8 @@ services: defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() // FYI: https://github.com/containerd/nerdctl/blob/e4b2b6da56555dc29ed66d0fd8e7094ff2bc002d/cmd/nerdctl/run_test.go#L177 - base.Env = append(os.Environ(), "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") - base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "-t=false", + base.Env = append(base.Env, "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") + base.ComposeCmd("-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY", "--env", "FOO=foo1,foo2", "--env", "BAR=bar1 bar2", "--env", "BAZ=", @@ -102,7 +97,7 @@ services: if !strings.Contains(stdout, "\nBAR=bar1 bar2\n") { return errors.New("got bad BAR") } - if !strings.Contains(stdout, "\nBAZ=\n") && runtime.GOOS != "windows" { + if !strings.Contains(stdout, "\nBAZ=\n") { return errors.New("got bad BAZ") } if strings.Contains(stdout, "QUX") { @@ -117,10 +112,10 @@ services: 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" { + if !strings.Contains(stdout, "\nGARPLY=\n") { return errors.New("got bad GARPLY") } - if !strings.Contains(stdout, "\nWALDO=\n") && runtime.GOOS != "windows" { + if !strings.Contains(stdout, "\nWALDO=\n") { return errors.New("got bad WALDO") } @@ -129,9 +124,6 @@ services: } func TestComposeExecWithUser(t *testing.T) { - // disabling `-it` in `compose exec` is only supported in compose v2. - // Currently CI is using compose v1. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -160,7 +152,7 @@ services: } for userStr, expected := range testCases { - args := []string{"-f", comp.YAMLFullPath(), "exec", "-i=false", "-t=false"} + args := []string{"-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY"} if userStr != "" { args = append(args, "--user", userStr) } @@ -171,8 +163,6 @@ services: func TestComposeExecTTY(t *testing.T) { // `-i` in `compose run & exec` is only supported in compose v2. - // Currently CI is using compose v1. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) if testutil.GetTarget() == testutil.Nerdctl { testutil.RequireDaemonVersion(base, ">= 1.6.0-0") @@ -204,6 +194,96 @@ services: unbuffer := []string{"unbuffer"} base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "svc0", "stty").AssertOutContains(sttyPartialOutput) // `-it` base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "-i=false", "svc0", "stty").AssertOutContains(sttyPartialOutput) // `-t` - base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "-t=false", "svc0", "stty").AssertFail() // `-i` - base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "-i=false", "-t=false", "svc0", "stty").AssertFail() + base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "--no-TTY", "svc0", "stty").AssertFail() // `-i` + base.ComposeCmdWithHelper(unbuffer, "-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY", "svc0", "stty").AssertFail() +} + +func TestComposeExecWithIndex(t *testing.T) { + base := testutil.NewBase(t) + var dockerComposeYAML = fmt.Sprintf(` +version: '3.1' + +services: + svc0: + image: %s + command: "sleep infinity" + deploy: + replicas: 3 +`, testutil.CommonImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + t.Cleanup(func() { + comp.CleanUp() + }) + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d", "svc0").AssertOK() + t.Cleanup(func() { + base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + }) + + // try 5 times to ensure that results are stable + for i := 0; i < 5; i++ { + for _, j := range []string{"1", "2", "3"} { + name := fmt.Sprintf("%s-svc0-%s", projectName, j) + host := fmt.Sprintf("%s.%s_default", name, projectName) + var ( + expectIP string + realIP string + ) + // docker and nerdctl have different DNS resolution behaviors. + // it uses the ID in the /etc/hosts file, so we need to fetch the ID first. + if testutil.GetTarget() == testutil.Docker { + base.Cmd("ps", "--filter", fmt.Sprintf("name=%s", name), "--format", "{{.ID}}").AssertOutWithFunc(func(stdout string) error { + host = strings.TrimSpace(stdout) + return nil + }) + } + cmds := []string{"-f", comp.YAMLFullPath(), "exec", "-i=false", "--no-TTY", "--index", j, "svc0"} + base.ComposeCmd(append(cmds, "cat", "/etc/hosts")...). + AssertOutWithFunc(func(stdout string) error { + lines := strings.Split(stdout, "\n") + for _, line := range lines { + if !strings.Contains(line, host) { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + expectIP = fields[0] + return nil + } + return errors.New("fail to get the expected ip address") + }) + base.ComposeCmd(append(cmds, "ip", "addr", "show", "dev", "eth0")...). + AssertOutWithFunc(func(stdout string) error { + ip := findIP(stdout) + if ip == nil { + return errors.New("fail to get the real ip address") + } + realIP = ip.String() + return nil + }) + assert.Equal(t, realIP, expectIP) + } + } +} + +func findIP(output string) net.IP { + var ip string + lines := strings.Split(output, "\n") + for _, line := range lines { + if !strings.Contains(line, "inet ") { + continue + } + fields := strings.Fields(line) + if len(fields) <= 1 { + continue + } + ip = strings.Split(fields[1], "/")[0] + break + } + return net.ParseIP(ip) } diff --git a/cmd/nerdctl/compose_images.go b/cmd/nerdctl/compose/compose_images.go similarity index 89% rename from cmd/nerdctl/compose_images.go rename to cmd/nerdctl/compose/compose_images.go index 36816769a0f..30212559e1d 100644 --- a/cmd/nerdctl/compose_images.go +++ b/cmd/nerdctl/compose/compose_images.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "context" @@ -22,17 +22,20 @@ import ( "strings" "text/tabwriter" - "github.com/containerd/containerd" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/containerd/snapshots" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/pkg/progress" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "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/strutil" ) func newComposeImagesCommand() *cobra.Command { @@ -49,7 +52,7 @@ func newComposeImagesCommand() *cobra.Command { } func composeImagesAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_images_linux_test.go b/cmd/nerdctl/compose/compose_images_linux_test.go similarity index 96% rename from cmd/nerdctl/compose_images_linux_test.go rename to cmd/nerdctl/compose/compose_images_linux_test.go index 85132a6890c..f9f7f475186 100644 --- a/cmd/nerdctl/compose_images_linux_test.go +++ b/cmd/nerdctl/compose/compose_images_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "encoding/json" @@ -22,7 +22,7 @@ import ( "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeImages(t *testing.T) { @@ -78,10 +78,6 @@ volumes: } func TestComposeImagesJson(t *testing.T) { - // `--format` is only supported in docker compose v2. - // Currently, CI is using docker compose v1. - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' diff --git a/cmd/nerdctl/compose_kill.go b/cmd/nerdctl/compose/compose_kill.go similarity index 85% rename from cmd/nerdctl/compose_kill.go rename to cmd/nerdctl/compose/compose_kill.go index 1e483273ff1..b1adcf4c753 100644 --- a/cmd/nerdctl/compose_kill.go +++ b/cmd/nerdctl/compose/compose_kill.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeKillCommand() *cobra.Command { @@ -36,7 +38,7 @@ func newComposeKillCommand() *cobra.Command { } func composeKillAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_kill_linux_test.go b/cmd/nerdctl/compose/compose_kill_linux_test.go similarity index 84% rename from cmd/nerdctl/compose_kill_linux_test.go rename to cmd/nerdctl/compose/compose_kill_linux_test.go index 3d948ebadb8..6571950a62e 100644 --- a/cmd/nerdctl/compose_kill_linux_test.go +++ b/cmd/nerdctl/compose/compose_kill_linux_test.go @@ -14,21 +14,17 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeKill(t *testing.T) { - // docker-compose v2 hides exited/killed containers in `compose ps`, and shows - // them if `-a` is passed, which is not supported yet by `nerdctl compose`. - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -73,6 +69,6 @@ volumes: 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").AssertOutContainsAny(" 137", "(137)") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny(" 137", "(137)") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") } diff --git a/cmd/nerdctl/compose_logs.go b/cmd/nerdctl/compose/compose_logs.go similarity index 89% rename from cmd/nerdctl/compose_logs.go rename to cmd/nerdctl/compose/compose_logs.go index 79902f7bad2..5a6fd723cc3 100644 --- a/cmd/nerdctl/compose_logs.go +++ b/cmd/nerdctl/compose/compose_logs.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeLogsCommand() *cobra.Command { @@ -40,7 +42,7 @@ func newComposeLogsCommand() *cobra.Command { } func composeLogsAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_pause.go b/cmd/nerdctl/compose/compose_pause.go similarity index 90% rename from cmd/nerdctl/compose_pause.go rename to cmd/nerdctl/compose/compose_pause.go index 429b7d853b0..9fd02bf3fa7 100644 --- a/cmd/nerdctl/compose_pause.go +++ b/cmd/nerdctl/compose/compose_pause.go @@ -14,12 +14,14 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" ) func newComposePauseCommand() *cobra.Command { @@ -35,7 +37,7 @@ func newComposePauseCommand() *cobra.Command { } func composePauseAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -70,7 +72,7 @@ func newComposeUnpauseCommand() *cobra.Command { } func composeUnpauseAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_pause_linux_test.go b/cmd/nerdctl/compose/compose_pause_linux_test.go similarity index 91% rename from cmd/nerdctl/compose_pause_linux_test.go rename to cmd/nerdctl/compose/compose_pause_linux_test.go index aaccca155bd..381e8686d6b 100644 --- a/cmd/nerdctl/compose_pause_linux_test.go +++ b/cmd/nerdctl/compose/compose_pause_linux_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposePauseAndUnpause(t *testing.T) { @@ -52,7 +52,7 @@ services: // pause a service should (only) pause its own container base.ComposeCmd("-f", comp.YAMLFullPath(), "pause", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Paused", "paused") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Paused", "paused") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1").AssertOutContainsAny("Up", "running") // unpause should be able to recover the paused service container diff --git a/cmd/nerdctl/compose_port.go b/cmd/nerdctl/compose/compose_port.go similarity index 89% rename from cmd/nerdctl/compose_port.go rename to cmd/nerdctl/compose/compose_port.go index 92c3ea3189a..2cbf14b0287 100644 --- a/cmd/nerdctl/compose_port.go +++ b/cmd/nerdctl/compose/compose_port.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package compose import ( "fmt" "strconv" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposePortCommand() *cobra.Command { @@ -42,7 +44,7 @@ func newComposePortCommand() *cobra.Command { } func composePortAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_port_linux_test.go b/cmd/nerdctl/compose/compose_port_linux_test.go similarity index 89% rename from cmd/nerdctl/compose_port_linux_test.go rename to cmd/nerdctl/compose/compose_port_linux_test.go index f5240edc55b..15946557ad2 100644 --- a/cmd/nerdctl/compose_port_linux_test.go +++ b/cmd/nerdctl/compose/compose_port_linux_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposePort(t *testing.T) { @@ -52,10 +52,6 @@ services: } func TestComposePortFailure(t *testing.T) { - // when no port mapping is found, docker compose v1 prints `\n` while v2 prints `:0\n` - // both v1 and v2 have exit code 0 (succeess) - // nerdctl compose will fail with error (no public port found). - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` diff --git a/cmd/nerdctl/compose_ps.go b/cmd/nerdctl/compose/compose_ps.go similarity index 59% rename from cmd/nerdctl/compose_ps.go rename to cmd/nerdctl/compose/compose_ps.go index 54f0ba1e596..1626b14cd5f 100644 --- a/cmd/nerdctl/compose_ps.go +++ b/cmd/nerdctl/compose/compose_ps.go @@ -14,24 +14,31 @@ limitations under the License. */ -package main +package compose import ( "context" "fmt" + "strings" "text/tabwriter" + "time" - "github.com/containerd/containerd" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/portutil" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" + + containerd "github.com/containerd/containerd/v2/client" + "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/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/portutil" ) func newComposePsCommand() *cobra.Command { @@ -42,7 +49,12 @@ func newComposePsCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - composePsCommand.Flags().String("format", "", "Format the output. Supported values: [json]") + composePsCommand.Flags().String("format", "table", "Format the output. Supported values: [table|json]") + composePsCommand.Flags().String("filter", "", "Filter matches containers based on given conditions") + composePsCommand.Flags().StringArray("status", []string{}, "Filter services by status. Values: [paused | restarting | removing | running | dead | created | exited]") + composePsCommand.Flags().BoolP("quiet", "q", false, "Only display container IDs") + composePsCommand.Flags().Bool("services", false, "Display services") + composePsCommand.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") return composePsCommand } @@ -63,7 +75,7 @@ type composeContainerPrintable struct { } func composePsAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -71,8 +83,40 @@ func composePsAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - if format != "json" && format != "" { - return fmt.Errorf("unsupported format %s, supported formats are: [json]", format) + if format != "json" && format != "table" { + return fmt.Errorf("unsupported format %s, supported formats are: [table|json]", format) + } + status, err := cmd.Flags().GetStringArray("status") + if err != nil { + return err + } + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return err + } + displayServices, err := cmd.Flags().GetBool("services") + if err != nil { + return err + } + filter, err := cmd.Flags().GetString("filter") + if err != nil { + return err + } + if filter != "" { + splited := strings.SplitN(filter, "=", 2) + if len(splited) != 2 { + return fmt.Errorf("invalid argument \"%s\" for \"-f, --filter\": bad format of filter (expected name=value)", filter) + } + // currently only the 'status' filter is supported + if splited[0] != "status" { + return fmt.Errorf("invalid filter '%s'", splited[0]) + } + status = append(status, splited[1]) + } + + all, err := cmd.Flags().GetBool("all") + if err != nil { + return err } client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) @@ -97,6 +141,41 @@ func composePsAction(cmd *cobra.Command, args []string) error { return err } + if !all { + var upContainers []containerd.Container + for _, container := range containers { + // cStatus := formatter.ContainerStatus(ctx, c) + cStatus, err := containerutil.ContainerStatus(ctx, container) + if err != nil { + continue + } + if cStatus.Status == containerd.Running { + upContainers = append(upContainers, container) + } + } + containers = upContainers + } + + if len(status) != 0 { + var filterdContainers []containerd.Container + for _, container := range containers { + cStatus := statusForFilter(ctx, container) + for _, s := range status { + if cStatus == s { + filterdContainers = append(filterdContainers, container) + } + } + } + containers = filterdContainers + } + + if quiet { + for _, c := range containers { + fmt.Fprintln(cmd.OutOrStdout(), c.ID()) + } + return nil + } + containersPrintable := make([]composeContainerPrintable, len(containers)) eg, ctx := errgroup.WithContext(ctx) for i, container := range containers { @@ -121,6 +200,12 @@ func composePsAction(cmd *cobra.Command, args []string) error { return err } + if displayServices { + for _, p := range containersPrintable { + fmt.Fprintln(cmd.OutOrStdout(), p.Service) + } + return nil + } if format == "json" { outJSON, err := formatter.ToJSON(containersPrintable, "", "") if err != nil { @@ -178,7 +263,7 @@ func composeContainerPrintableTab(ctx context.Context, container containerd.Cont }, nil } -// composeContainerPrintableTab constructs composeContainerPrintable with fields +// composeContainerPrintableJSON constructs composeContainerPrintable with fields // only for json output and compatible docker output. func composeContainerPrintableJSON(ctx context.Context, container containerd.Container) (composeContainerPrintable, error) { info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata) @@ -198,9 +283,11 @@ func composeContainerPrintableJSON(ctx context.Context, container containerd.Con if err == nil { // show exitCode only when container is exited/stopped if status.Status == containerd.Stopped { + state = "exited" exitCode = status.ExitStatus + } else { + state = string(status.Status) } - state = string(status.Status) } else { state = string(containerd.Unknown) } @@ -236,7 +323,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 { - mapper := func(pm gocni.PortMapping) PortPublisher { + mapper := func(pm cni.PortMapping) PortPublisher { return PortPublisher{ URL: pm.HostIP, TargetPort: int(pm.ContainerPort), @@ -251,7 +338,45 @@ func formatPublishers(labelMap map[string]string) []PortPublisher { dockerPorts = append(dockerPorts, mapper(p)) } } else { - logrus.Error(err.Error()) + log.L.Error(err.Error()) } return dockerPorts } + +// statusForFilter returns the status value to be matched with the 'status' filter +func statusForFilter(ctx context.Context, c containerd.Container) string { + // Just in case, there is something wrong in server. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + task, err := c.Task(ctx, nil) + if err != nil { + // NOTE: NotFound doesn't mean that container hasn't started. + // In docker/CRI-containerd plugin, the task will be deleted + // when it exits. So, the status will be "created" for this + // case. + if errdefs.IsNotFound(err) { + return string(containerd.Created) + } + return string(containerd.Unknown) + } + + status, err := task.Status(ctx) + if err != nil { + return string(containerd.Unknown) + } + labels, err := c.Labels(ctx) + if err != nil { + return string(containerd.Unknown) + } + + switch s := status.Status; s { + case containerd.Stopped: + if labels[restart.StatusLabel] == string(containerd.Running) && restart.Reconcile(status, labels) { + return "restarting" + } + return "exited" + default: + return string(s) + } +} diff --git a/cmd/nerdctl/compose_ps_linux_test.go b/cmd/nerdctl/compose/compose_ps_linux_test.go similarity index 56% rename from cmd/nerdctl/compose_ps_linux_test.go rename to cmd/nerdctl/compose/compose_ps_linux_test.go index 7fdda4a47ae..df6f1d3cfe5 100644 --- a/cmd/nerdctl/compose_ps_linux_test.go +++ b/cmd/nerdctl/compose/compose_ps_linux_test.go @@ -14,20 +14,100 @@ limitations under the License. */ -package main +package compose import ( "encoding/json" "fmt" "strings" "testing" + "time" - "github.com/containerd/nerdctl/pkg/testutil" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) +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 + WORDPRESS_DB_PASSWORD: examplepass + WORDPRESS_DB_NAME: exampledb + volumes: + - wordpress:/var/www/html + db: + image: %s + container_name: db_container + environment: + MYSQL_DATABASE: exampledb + MYSQL_USER: exampleuser + MYSQL_PASSWORD: examplepass + MYSQL_RANDOM_ROOT_PASSWORD: '1' + volumes: + - db:/var/lib/mysql + alpine: + image: %s + container_name: alpine_container + +volumes: + wordpress: + db: +`, testutil.WordpressImage, testutil.MariaDBImage, testutil.AlpineImage) + 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(expectedName, expectedImage string) func(stdout string) error { + return 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("NAME\tIMAGE\tCOMMAND\tSERVICE\tSTATUS\tPORTS") + err := tab.ParseHeader(lines[0]) + if err != nil { + return fmt.Errorf("failed to parse header: %v", err) + } + + container, _ := tab.ReadRow(lines[1], "NAME") + assert.Equal(t, container, expectedName) + + image, _ := tab.ReadRow(lines[1], "IMAGE") + assert.Equal(t, image, expectedImage) + + return nil + } + + } + + 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", "--services", "-a").AssertOutContainsAll("wordpress\n", "db\n", "alpine\n") +} + func TestComposePsJSON(t *testing.T) { - // `--format` is only supported in docker compose v2. - // Currently, CI is using docker compose v1. + // docker parses unknown 'format' as a Go template and won't output an error testutil.DockerIncompatible(t) base := testutil.NewBase(t) @@ -101,8 +181,8 @@ volumes: AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"running"`, `"TargetPort":80`, `"PublishedPort":8080`)) // check wordpress is stopped base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "wordpress").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress"). - AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"stopped"`)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress", "-a"). + AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"exited"`)) // check wordpress is removed base.ComposeCmd("-f", comp.YAMLFullPath(), "rm", "-f", "wordpress").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress"). diff --git a/cmd/nerdctl/compose_pull.go b/cmd/nerdctl/compose/compose_pull.go similarity index 85% rename from cmd/nerdctl/compose_pull.go rename to cmd/nerdctl/compose/compose_pull.go index 9d004353fc5..9763377d4a5 100644 --- a/cmd/nerdctl/compose_pull.go +++ b/cmd/nerdctl/compose/compose_pull.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposePullCommand() *cobra.Command { @@ -36,7 +38,7 @@ func newComposePullCommand() *cobra.Command { } func composePullAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_pull_linux_test.go b/cmd/nerdctl/compose/compose_pull_linux_test.go similarity index 94% rename from cmd/nerdctl/compose_pull_linux_test.go rename to cmd/nerdctl/compose/compose_pull_linux_test.go index d350892f1b3..64e267baa24 100644 --- a/cmd/nerdctl/compose_pull_linux_test.go +++ b/cmd/nerdctl/compose/compose_pull_linux_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposePullWithService(t *testing.T) { @@ -62,5 +62,5 @@ volumes: projectName := comp.ProjectName() t.Logf("projectName=%q", projectName) - base.ComposeCmd("-f", comp.YAMLFullPath(), "pull", "db").AssertNoOut("wordpress") + base.ComposeCmd("-f", comp.YAMLFullPath(), "pull", "db").AssertOutNotContains("wordpress") } diff --git a/cmd/nerdctl/compose_push.go b/cmd/nerdctl/compose/compose_push.go similarity index 84% rename from cmd/nerdctl/compose_push.go rename to cmd/nerdctl/compose/compose_push.go index 2c899dabf39..50063d556e9 100644 --- a/cmd/nerdctl/compose_push.go +++ b/cmd/nerdctl/compose/compose_push.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposePushCommand() *cobra.Command { @@ -35,7 +37,7 @@ func newComposePushCommand() *cobra.Command { } func composePushAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_restart.go b/cmd/nerdctl/compose/compose_restart.go similarity index 86% rename from cmd/nerdctl/compose_restart.go rename to cmd/nerdctl/compose/compose_restart.go index adb47e3dee4..0ee3a936cf1 100644 --- a/cmd/nerdctl/compose_restart.go +++ b/cmd/nerdctl/compose/compose_restart.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeRestartCommand() *cobra.Command { @@ -36,7 +38,7 @@ func newComposeRestartCommand() *cobra.Command { } func composeRestartAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_restart_linux_test.go b/cmd/nerdctl/compose/compose_restart_linux_test.go similarity index 84% rename from cmd/nerdctl/compose_restart_linux_test.go rename to cmd/nerdctl/compose/compose_restart_linux_test.go index 8de3513fd41..6d5fe1fdedc 100644 --- a/cmd/nerdctl/compose_restart_linux_test.go +++ b/cmd/nerdctl/compose/compose_restart_linux_test.go @@ -14,19 +14,16 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeRestart(t *testing.T) { - // docker-compose v2 hides exited containers in `compose ps`, and shows - // them if `-a` is passed, which is not supported yet by `nerdctl compose`. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -68,13 +65,13 @@ volumes: // stop and restart a single service. base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") base.ComposeCmd("-f", comp.YAMLFullPath(), "restart", "db").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") // stop one service and restart all (also check `--timeout` arg). base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") base.ComposeCmd("-f", comp.YAMLFullPath(), "restart", "--timeout", "5").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") diff --git a/cmd/nerdctl/compose_rm.go b/cmd/nerdctl/compose/compose_rm.go similarity index 84% rename from cmd/nerdctl/compose_rm.go rename to cmd/nerdctl/compose/compose_rm.go index 6ac686f6b86..d82e345db76 100644 --- a/cmd/nerdctl/compose_rm.go +++ b/cmd/nerdctl/compose/compose_rm.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package compose import ( "fmt" "strings" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeRemoveCommand() *cobra.Command { @@ -41,7 +43,7 @@ func newComposeRemoveCommand() *cobra.Command { } func composeRemoveAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -50,18 +52,15 @@ func composeRemoveAction(cmd *cobra.Command, args []string) error { return err } if !force { - var confirm string services := "all" if len(args) != 0 { services = strings.Join(args, ",") } + msg := fmt.Sprintf("This will remove all stopped containers from services: %s.", services) - msg += "\nAre you sure you want to continue? [y/N] " - fmt.Fprintf(cmd.OutOrStdout(), "WARNING! %s", msg) - fmt.Fscanf(cmd.InOrStdin(), "%s", &confirm) - if strings.ToLower(confirm) != "y" { - return nil + if confirmed, err := helpers.Confirm(cmd, fmt.Sprintf("WARNING! %s.", msg)); err != nil || !confirmed { + return err } } diff --git a/cmd/nerdctl/compose_rm_linux_test.go b/cmd/nerdctl/compose/compose_rm_linux_test.go similarity index 97% rename from cmd/nerdctl/compose_rm_linux_test.go rename to cmd/nerdctl/compose/compose_rm_linux_test.go index a699274883d..948ea9e119d 100644 --- a/cmd/nerdctl/compose_rm_linux_test.go +++ b/cmd/nerdctl/compose/compose_rm_linux_test.go @@ -14,14 +14,14 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeRemove(t *testing.T) { diff --git a/cmd/nerdctl/compose_run.go b/cmd/nerdctl/compose/compose_run.go similarity index 96% rename from cmd/nerdctl/compose_run.go rename to cmd/nerdctl/compose/compose_run.go index c92c5ab1ae9..afe02895ae0 100644 --- a/cmd/nerdctl/compose_run.go +++ b/cmd/nerdctl/compose/compose_run.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package compose import ( "errors" "fmt" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeRunCommand() *cobra.Command { @@ -68,7 +70,7 @@ func newComposeRunCommand() *cobra.Command { } func composeRunAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_run_linux_test.go b/cmd/nerdctl/compose/compose_run_linux_test.go similarity index 94% rename from cmd/nerdctl/compose_run_linux_test.go rename to cmd/nerdctl/compose/compose_run_linux_test.go index a464e53418a..65b36e7ffb6 100644 --- a/cmd/nerdctl/compose_run_linux_test.go +++ b/cmd/nerdctl/compose/compose_run_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "fmt" @@ -23,11 +23,14 @@ import ( "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "github.com/sirupsen/logrus" "gotest.tools/v3/assert" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" ) func TestComposeRun(t *testing.T) { @@ -94,7 +97,7 @@ services: stdoutContent := result.Stdout() + result.Stderr() assert.Assert(psCmd.Base.T, result.ExitCode == 0, stdoutContent) if strings.Contains(stdoutContent, containerName) { - logrus.Errorf("test failed, the container %s is not removed", stdoutContent) + log.L.Errorf("test failed, the container %s is not removed", stdoutContent) t.Fail() return } @@ -316,7 +319,7 @@ services: container := base.InspectContainer(containerName) if container.Config == nil { - logrus.Errorf("test failed, cannot fetch container config") + log.L.Errorf("test failed, cannot fetch container config") t.Fail() } assert.Equal(t, container.Config.Labels["foo"], "rab") @@ -424,21 +427,21 @@ func TestComposePushAndPullWithCosignVerify(t *testing.T) { testutil.RequireExecutable(t, "cosign") testutil.DockerIncompatible(t) testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + t.Parallel() + base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() + base.Env = append(base.Env, "COSIGN_PASSWORD=1") - // set up cosign and local registry - t.Setenv("COSIGN_PASSWORD", "1") - keyPair := newCosignKeyPair(t, "cosign-key-pair") - defer keyPair.cleanup() + keyPair := helpers.NewCosignKeyPair(t, "cosign-key-pair", "1") + reg := testregistry.NewWithNoAuth(base, 0, false) + t.Cleanup(func() { + keyPair.Cleanup() + reg.Cleanup(nil) + }) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - localhostIP := "127.0.0.1" - t.Logf("localhost IP=%q", localhostIP) - testImageRefPrefix := fmt.Sprintf("%s:%d/", - localhostIP, reg.ListenPort) - t.Logf("testImageRefPrefix=%q", testImageRefPrefix) + tID := testutil.Identifier(t) + testImageRefPrefix := fmt.Sprintf("127.0.0.1:%d/%s/", reg.Port, tID) var ( imageSvc0 = testImageRefPrefix + "composebuild_svc0" @@ -473,8 +476,8 @@ services: x-nerdctl-sign: none entrypoint: - stty -`, imageSvc0, keyPair.publicKey, keyPair.privateKey, - imageSvc1, keyPair.privateKey, imageSvc2) +`, imageSvc0, keyPair.PublicKey, keyPair.PrivateKey, + imageSvc1, keyPair.PrivateKey, imageSvc2) dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) diff --git a/cmd/nerdctl/compose_start.go b/cmd/nerdctl/compose/compose_start.go similarity index 88% rename from cmd/nerdctl/compose_start.go rename to cmd/nerdctl/compose/compose_start.go index a5889b18aaa..ce0cde46acc 100644 --- a/cmd/nerdctl/compose_start.go +++ b/cmd/nerdctl/compose/compose_start.go @@ -14,21 +14,24 @@ limitations under the License. */ -package main +package compose import ( "context" "fmt" "os" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/labels" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func newComposeStartCommand() *cobra.Command { @@ -44,7 +47,7 @@ func newComposeStartCommand() *cobra.Command { } func composeStartAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_start_linux_test.go b/cmd/nerdctl/compose/compose_start_linux_test.go similarity index 91% rename from cmd/nerdctl/compose_start_linux_test.go rename to cmd/nerdctl/compose/compose_start_linux_test.go index 64567528093..11c1581cd92 100644 --- a/cmd/nerdctl/compose_start_linux_test.go +++ b/cmd/nerdctl/compose/compose_start_linux_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeStart(t *testing.T) { @@ -57,10 +57,6 @@ services: } func TestComposeStartFailWhenServicePause(t *testing.T) { - // Incompatible with docker compose v1. Currently CI is using compose v1. - // Starting a paused container triggers an error in v2 but is ignored in v1. - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) switch base.Info().CgroupDriver { case "none", "": diff --git a/cmd/nerdctl/compose_stop.go b/cmd/nerdctl/compose/compose_stop.go similarity index 86% rename from cmd/nerdctl/compose_stop.go rename to cmd/nerdctl/compose/compose_stop.go index b35d4911143..2f3382a9822 100644 --- a/cmd/nerdctl/compose_stop.go +++ b/cmd/nerdctl/compose/compose_stop.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package compose import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeStopCommand() *cobra.Command { @@ -36,7 +38,7 @@ func newComposeStopCommand() *cobra.Command { } func composeStopAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } diff --git a/cmd/nerdctl/compose_stop_linux_test.go b/cmd/nerdctl/compose/compose_stop_linux_test.go similarity index 81% rename from cmd/nerdctl/compose_stop_linux_test.go rename to cmd/nerdctl/compose/compose_stop_linux_test.go index cadff7f30fd..e10b16ff7b2 100644 --- a/cmd/nerdctl/compose_stop_linux_test.go +++ b/cmd/nerdctl/compose/compose_stop_linux_test.go @@ -14,19 +14,16 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeStop(t *testing.T) { - // docker-compose v2 hides exited/killed containers in `compose ps`, and shows - // them if `-a` is passed, which is not supported yet by `nerdctl compose`. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -70,11 +67,11 @@ volumes: // stop should (only) stop the given service. base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + 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").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress", "-a").AssertOutContainsAny("Exit", "exited") } diff --git a/cmd/nerdctl/compose/compose_test.go b/cmd/nerdctl/compose/compose_test.go new file mode 100644 index 00000000000..efcfd184e5b --- /dev/null +++ b/cmd/nerdctl/compose/compose_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 compose + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/compose_top.go b/cmd/nerdctl/compose/compose_top.go similarity index 80% rename from cmd/nerdctl/compose_top.go rename to cmd/nerdctl/compose/compose_top.go index 254e1c7a557..0bb8dadccea 100644 --- a/cmd/nerdctl/compose_top.go +++ b/cmd/nerdctl/compose/compose_top.go @@ -14,19 +14,22 @@ limitations under the License. */ -package main +package compose import ( "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/labels" "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + + "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/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func newComposeTopCommand() *cobra.Command { @@ -42,7 +45,7 @@ func newComposeTopCommand() *cobra.Command { } func composeTopAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -82,7 +85,7 @@ func composeTopAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Fprintf(stdout, "%s\n", info.Labels[labels.Name]) + fmt.Fprintln(stdout, info.Labels[labels.Name]) // `compose ps` uses empty ps args err = container.Top(ctx, client, []string{c.ID()}, types.ContainerTopOptions{ Stdout: cmd.OutOrStdout(), diff --git a/cmd/nerdctl/compose_top_linux_test.go b/cmd/nerdctl/compose/compose_top_linux_test.go similarity index 90% rename from cmd/nerdctl/compose_top_linux_test.go rename to cmd/nerdctl/compose/compose_top_linux_test.go index 38a94fa84e9..a0474c51b0b 100644 --- a/cmd/nerdctl/compose_top_linux_test.go +++ b/cmd/nerdctl/compose/compose_top_linux_test.go @@ -14,15 +14,15 @@ limitations under the License. */ -package main +package compose import ( "fmt" "testing" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeTop(t *testing.T) { diff --git a/cmd/nerdctl/compose_up.go b/cmd/nerdctl/compose/compose_up.go similarity index 63% rename from cmd/nerdctl/compose_up.go rename to cmd/nerdctl/compose/compose_up.go index 019bb795d10..0cf2d6bccd2 100644 --- a/cmd/nerdctl/compose_up.go +++ b/cmd/nerdctl/compose/compose_up.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "errors" @@ -22,10 +22,12 @@ import ( "strconv" "strings" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/compose" - "github.com/containerd/nerdctl/pkg/composer" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/compose" + "github.com/containerd/nerdctl/v2/pkg/composer" ) func newComposeUpCommand() *cobra.Command { @@ -36,7 +38,8 @@ func newComposeUpCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - composeUpCommand.Flags().BoolP("detach", "d", false, "Detached mode: Run containers in the background") + composeUpCommand.Flags().Bool("abort-on-container-exit", false, "Stops all containers if any container was stopped. Incompatible with -d.") + composeUpCommand.Flags().BoolP("detach", "d", false, "Detached mode: Run containers in the background. Incompatible with --abort-on-container-exit.") composeUpCommand.Flags().Bool("no-build", false, "Don't build an image, even if it's missing.") composeUpCommand.Flags().Bool("no-color", false, "Produce monochrome output") composeUpCommand.Flags().Bool("no-log-prefix", false, "Don't print prefix in logs") @@ -44,12 +47,15 @@ func newComposeUpCommand() *cobra.Command { composeUpCommand.Flags().Bool("ipfs", false, "Allow pulling base images from IPFS during build") composeUpCommand.Flags().Bool("quiet-pull", false, "Pull without printing progress information") composeUpCommand.Flags().Bool("remove-orphans", false, "Remove containers for services not defined in the Compose file.") + composeUpCommand.Flags().Bool("force-recreate", false, "Recreate containers even if their configuration and image haven't changed.") + composeUpCommand.Flags().Bool("no-recreate", false, "Don't recreate containers if they exist, conflict with --force-recreate.") composeUpCommand.Flags().StringArray("scale", []string{}, "Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.") + composeUpCommand.Flags().String("pull", "", "Pull image before running (\"always\"|\"missing\"|\"never\")") return composeUpCommand } func composeUpAction(cmd *cobra.Command, services []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -57,6 +63,13 @@ func composeUpAction(cmd *cobra.Command, services []string) error { if err != nil { return err } + abortOnContainerExit, err := cmd.Flags().GetBool("abort-on-container-exit") + if detach && abortOnContainerExit { + return fmt.Errorf("--abort-on-container-exit flag is incompatible with flag --detach") + } + if err != nil { + return err + } noBuild, err := cmd.Flags().GetBool("no-build") if err != nil { return err @@ -84,6 +97,10 @@ func composeUpAction(cmd *cobra.Command, services []string) error { if err != nil { return err } + pull, err := cmd.Flags().GetString("pull") + if err != nil { + return err + } removeOrphans, err := cmd.Flags().GetBool("remove-orphans") if err != nil { return err @@ -92,7 +109,18 @@ func composeUpAction(cmd *cobra.Command, services []string) error { if err != nil { return err } - scale := make(map[string]uint64) + forceRecreate, err := cmd.Flags().GetBool("force-recreate") + if err != nil { + return err + } + noRecreate, err := cmd.Flags().GetBool("no-recreate") + if err != nil { + return err + } + if forceRecreate && noRecreate { + return errors.New("flag --force-recreate and --no-recreate cannot be specified together") + } + scale := make(map[string]int) for _, s := range scaleSlice { parts := strings.Split(s, "=") if len(parts) != 2 { @@ -102,7 +130,7 @@ func composeUpAction(cmd *cobra.Command, services []string) error { if err != nil { return err } - scale[parts[0]] = uint64(replicas) + scale[parts[0]] = replicas } client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) @@ -121,15 +149,19 @@ func composeUpAction(cmd *cobra.Command, services []string) error { } uo := composer.UpOptions{ - Detach: detach, - NoBuild: noBuild, - NoColor: noColor, - NoLogPrefix: noLogPrefix, - ForceBuild: build, - IPFS: enableIPFS, - QuietPull: quietPull, - RemoveOrphans: removeOrphans, - Scale: scale, + AbortOnContainerExit: abortOnContainerExit, + Detach: detach, + NoBuild: noBuild, + NoColor: noColor, + NoLogPrefix: noLogPrefix, + ForceBuild: build, + IPFS: enableIPFS, + QuietPull: quietPull, + RemoveOrphans: removeOrphans, + Scale: scale, + Pull: pull, + ForceRecreate: forceRecreate, + NoRecreate: noRecreate, } return c.Up(ctx, uo, services) } diff --git a/cmd/nerdctl/compose_up_linux_test.go b/cmd/nerdctl/compose/compose_up_linux_test.go similarity index 79% rename from cmd/nerdctl/compose_up_linux_test.go rename to cmd/nerdctl/compose/compose_up_linux_test.go index c6b5c34773a..6da3162b4b9 100644 --- a/cmd/nerdctl/compose_up_linux_test.go +++ b/cmd/nerdctl/compose/compose_up_linux_test.go @@ -14,30 +14,31 @@ limitations under the License. */ -package main +package compose import ( "fmt" "io" - "os" "strings" "testing" "time" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/rootlessutil" "github.com/docker/go-connections/nat" - "github.com/sirupsen/logrus" + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" + "github.com/containerd/log" - "gotest.tools/v3/assert" + "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/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" ) func TestComposeUp(t *testing.T) { base := testutil.NewBase(t) - testComposeUp(t, base, fmt.Sprintf(` + helpers.ComposeUp(t, base, fmt.Sprintf(` version: '3.1' services: @@ -72,61 +73,10 @@ volumes: `, testutil.WordpressImage, testutil.MariaDBImage)) } -func testComposeUp(t *testing.T, base *testutil.Base, dockerComposeYAML string, opts ...string) { - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - - projectName := comp.ProjectName() - t.Logf("projectName=%q", projectName) - - base.ComposeCmd(append(append([]string{"-f", comp.YAMLFullPath()}, opts...), "up", "-d")...).AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - base.Cmd("volume", "inspect", fmt.Sprintf("%s_db", projectName)).AssertOK() - 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) - if err != nil { - return err - } - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if !strings.Contains(string(respBody), testutil.WordpressIndexHTMLSnippet) { - t.Logf("respBody=%q", respBody) - return fmt.Errorf("respBody does not contain %q", testutil.WordpressIndexHTMLSnippet) - } - return nil - } - - var wordpressWorking bool - for i := 0; i < 30; i++ { - t.Logf("(retry %d)", i) - err := checkWordpress() - if err == nil { - wordpressWorking = true - break - } - // NOTE: "

Error establishing a database connection

" is expected for the first few iterations - t.Log(err) - time.Sleep(3 * time.Second) - } - - if !wordpressWorking { - t.Fatal("wordpress is not working") - } - t.Log("wordpress seems functional") - - base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() - base.Cmd("volume", "inspect", fmt.Sprintf("%s_db", projectName)).AssertFail() - base.Cmd("network", "inspect", fmt.Sprintf("%s_default", projectName)).AssertFail() -} - func TestComposeUpBuild(t *testing.T) { testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() const dockerComposeYAML = ` services: @@ -194,7 +144,7 @@ networks: stdoutContent := result.Stdout() + result.Stderr() assert.Assert(inspectCmd.Base.T, result.ExitCode == 0, stdoutContent) if !strings.Contains(stdoutContent, staticIP) { - logrus.Errorf("test failed, the actual container ip is %s", stdoutContent) + log.L.Errorf("test failed, the actual container ip is %s", stdoutContent) t.Fail() return } @@ -268,7 +218,7 @@ services: projectName := comp.ProjectName() t.Logf("projectName=%q", projectName) - base.Env = append(os.Environ(), "ADDRESS=0.0.0.0") + base.Env = append(base.Env, "ADDRESS=0.0.0.0") base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() @@ -505,7 +455,7 @@ func TestComposeUpWithBypass4netns(t *testing.T) { testutil.RequireKernelVersion(t, ">= 5.9.0-0") testutil.RequireSystemService(t, "bypass4netnsd") base := testutil.NewBase(t) - testComposeUp(t, base, fmt.Sprintf(` + helpers.ComposeUp(t, base, fmt.Sprintf(` version: '3.1' services: @@ -522,7 +472,7 @@ services: WORDPRESS_DB_NAME: exampledb volumes: - wordpress:/var/www/html - labels: + annotations: - nerdctl/bypass4netns=1 db: @@ -535,7 +485,7 @@ services: MYSQL_RANDOM_ROOT_PASSWORD: '1' volumes: - db:/var/lib/mysql - labels: + annotations: - nerdctl/bypass4netns=1 volumes: @@ -585,3 +535,101 @@ services: psCmd.AssertOutContains(serviceRegular) psCmd.AssertOutNotContains(serviceProfiled) } + +func TestComposeUpAbortOnContainerExit(t *testing.T) { + base := testutil.NewBase(t) + serviceRegular := "regular" + serviceProfiled := "exited" + dockerComposeYAML := 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) +} + +func TestComposeUpPull(t *testing.T) { + base := testutil.NewBase(t) + + var dockerComposeYAML = 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) + }) +} + +func TestComposeUpServicePullPolicy(t *testing.T) { + base := testutil.NewBase(t) + + var dockerComposeYAML = fmt.Sprintf(` +services: + test: + image: %s + command: sh -euxc "echo hi" + pull_policy: "never" +`, testutil.CommonImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + base.Cmd("rmi", "-f", testutil.CommonImage).Run() + base.Cmd("images").AssertOutNotContains(testutil.CommonImage) + base.ComposeCmd("-f", comp.YAMLFullPath(), "up").AssertExitCode(1) +} diff --git a/cmd/nerdctl/compose_up_test.go b/cmd/nerdctl/compose/compose_up_test.go similarity index 94% rename from cmd/nerdctl/compose_up_test.go rename to cmd/nerdctl/compose/compose_up_test.go index ef1811fcd8e..63bba829fcf 100644 --- a/cmd/nerdctl/compose_up_test.go +++ b/cmd/nerdctl/compose/compose_up_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package compose import ( "fmt" @@ -23,9 +23,10 @@ import ( "runtime" "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) // https://github.com/containerd/nerdctl/issues/1942 @@ -49,7 +50,7 @@ services: Err: `exec: \"invalid\": executable file not found in $PATH`, } if base.Target == testutil.Docker { - expected.Err = `Unknown runtime specified invalid` + expected.Err = `unknown or invalid runtime name: invalid` } c.Assert(expected) } diff --git a/cmd/nerdctl/compose_version.go b/cmd/nerdctl/compose/compose_version.go similarity index 92% rename from cmd/nerdctl/compose_version.go rename to cmd/nerdctl/compose/compose_version.go index dbea1318ec6..d4a74a4bca0 100644 --- a/cmd/nerdctl/compose_version.go +++ b/cmd/nerdctl/compose/compose_version.go @@ -14,14 +14,15 @@ limitations under the License. */ -package main +package compose import ( "fmt" "strings" - "github.com/containerd/nerdctl/pkg/version" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/pkg/version" ) func newComposeVersionCommand() *cobra.Command { @@ -47,7 +48,7 @@ func composeVersionAction(cmd *cobra.Command, args []string) error { return err } if short { - fmt.Fprintln(cmd.OutOrStdout(), strings.TrimPrefix(version.Version, "v")) + fmt.Fprintln(cmd.OutOrStdout(), strings.TrimPrefix(version.GetVersion(), "v")) return nil } @@ -57,7 +58,7 @@ func composeVersionAction(cmd *cobra.Command, args []string) error { } switch format { case "pretty": - fmt.Fprintln(cmd.OutOrStdout(), "nerdctl Compose version "+version.Version) + fmt.Fprintln(cmd.OutOrStdout(), "nerdctl Compose version "+version.GetVersion()) case "json": fmt.Fprintf(cmd.OutOrStdout(), "{\"version\":\"%v\"}\n", version.Version) default: diff --git a/cmd/nerdctl/compose_version_test.go b/cmd/nerdctl/compose/compose_version_test.go similarity index 94% rename from cmd/nerdctl/compose_version_test.go rename to cmd/nerdctl/compose/compose_version_test.go index 2f3354745f3..af3028b3d65 100644 --- a/cmd/nerdctl/compose_version_test.go +++ b/cmd/nerdctl/compose/compose_version_test.go @@ -14,12 +14,12 @@ limitations under the License. */ -package main +package compose import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestComposeVersion(t *testing.T) { diff --git a/cmd/nerdctl/container.go b/cmd/nerdctl/container/container.go similarity index 59% rename from cmd/nerdctl/container.go rename to cmd/nerdctl/container/container.go index 6c0a763f14b..21c7f9b63b1 100644 --- a/cmd/nerdctl/container.go +++ b/cmd/nerdctl/container/container.go @@ -14,48 +14,53 @@ limitations under the License. */ -package main +package container import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newContainerCommand() *cobra.Command { +func NewContainerCommand() *cobra.Command { containerCommand := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "container", Short: "Manage containers", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } containerCommand.AddCommand( - newCreateCommand(), - newRunCommand(), - newUpdateCommand(), - newExecCommand(), + NewCreateCommand(), + NewRunCommand(), + NewUpdateCommand(), + NewExecCommand(), containerLsCommand(), newContainerInspectCommand(), - newLogsCommand(), - newPortCommand(), - newRmCommand(), - newStopCommand(), - newStartCommand(), - newRestartCommand(), - newKillCommand(), - newPauseCommand(), - newWaitCommand(), - newUnpauseCommand(), - newCommitCommand(), - newRenameCommand(), + NewLogsCommand(), + NewPortCommand(), + NewRmCommand(), + NewStopCommand(), + NewStartCommand(), + NewRestartCommand(), + NewKillCommand(), + NewPauseCommand(), + NewDiffCommand(), + NewWaitCommand(), + NewUnpauseCommand(), + NewCommitCommand(), + NewRenameCommand(), newContainerPruneCommand(), + NewStatsCommand(), + NewAttachCommand(), ) - addCpCommand(containerCommand) + AddCpCommand(containerCommand) return containerCommand } func containerLsCommand() *cobra.Command { - x := newPsCommand() + x := NewPsCommand() x.Use = "ls" x.Aliases = []string{"list"} return x diff --git a/cmd/nerdctl/container/container_attach.go b/cmd/nerdctl/container/container_attach.go new file mode 100644 index 00000000000..e92aa44b26d --- /dev/null +++ b/cmd/nerdctl/container/container_attach.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 container + +import ( + "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/consoleutil" +) + +func NewAttachCommand() *cobra.Command { + const shortHelp = "Attach stdin, stdout, and stderr to a running container." + const longHelp = `Attach stdin, stdout, and stderr to a running container. For example: + +1. 'nerdctl run -it --name test busybox' to start a container with a pty +2. 'ctrl-p ctrl-q' to detach from the container +3. 'nerdctl attach test' to attach to the container + +Caveats: + +- Currently only one attach session is allowed. When the second session tries to attach, currently no error will be returned from nerdctl. + However, since behind the scenes, there's only one FIFO for stdin, stdout, and stderr respectively, + if there are multiple sessions, all the sessions will be reading from and writing to the same 3 FIFOs, which will result in mixed input and partial output. +- Until dual logging (issue #1946) is implemented, + a container that is spun up by either 'nerdctl run -d' or 'nerdctl start' (without '--attach') cannot be attached to.` + + var attachCommand = &cobra.Command{ + Use: "attach [flags] CONTAINER", + Args: cobra.ExactArgs(1), + Short: shortHelp, + Long: longHelp, + RunE: containerAttachAction, + ValidArgsFunction: attachShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + attachCommand.Flags().String("detach-keys", consoleutil.DefaultDetachKeys, "Override the default detach keys") + return attachCommand +} + +func processContainerAttachOptions(cmd *cobra.Command) (types.ContainerAttachOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ContainerAttachOptions{}, err + } + detachKeys, err := cmd.Flags().GetString("detach-keys") + if err != nil { + return types.ContainerAttachOptions{}, err + } + return types.ContainerAttachOptions{ + GOptions: globalOptions, + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + DetachKeys: detachKeys, + }, nil +} + +func containerAttachAction(cmd *cobra.Command, args []string) error { + options, err := processContainerAttachOptions(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 container.Attach(ctx, client, args[0], options) +} + +func attachShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + statusFilterFn := func(st containerd.ProcessStatus) bool { + return st == containerd.Running + } + return completion.ContainerNames(cmd, statusFilterFn) +} diff --git a/cmd/nerdctl/container/container_attach_linux_test.go b/cmd/nerdctl/container/container_attach_linux_test.go new file mode 100644 index 00000000000..8d90897230e --- /dev/null +++ b/cmd/nerdctl/container/container_attach_linux_test.go @@ -0,0 +1,161 @@ +/* + 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 ( + "bytes" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +// skipAttachForDocker should be called by attach-related tests that assert 'read detach keys' in stdout. +func skipAttachForDocker(t *testing.T) { + t.Helper() + if testutil.GetTarget() == testutil.Docker { + t.Skip("When detaching from a container, for a session started with 'docker attach'" + + ", it prints 'read escape sequence', but for one started with 'docker (run|start)', it prints nothing." + + " However, the flag is called '--detach-keys' in all cases" + + ", so nerdctl prints 'read detach keys' for all cases" + + ", and that's why this test is skipped for Docker.") + } +} + +// prepareContainerToAttach spins up a container (entrypoint = shell) with `-it` and detaches from it +// so that it can be re-attached to later. +func prepareContainerToAttach(base *testutil.Base, containerName string) { + opts := []func(*testutil.Cmd){ + testutil.WithStdin(testutil.NewDelayOnceReader(bytes.NewReader( + []byte{16, 17}, // ctrl+p,ctrl+q, see https://www.physics.udel.edu/~watson/scen103/ascii.html + ))), + } + // unbuffer(1) emulates tty, which is required by `nerdctl run -t`. + // unbuffer(1) can be installed with `apt-get install expect`. + // + // "-p" is needed because we need unbuffer to read from stdin, and from [1]: + // "Normally, unbuffer does not read from stdin. This simplifies use of unbuffer in some situations. + // To use unbuffer in a pipeline, use the -p flag." + // + // [1] https://linux.die.net/man/1/unbuffer + base.CmdWithHelper([]string{"unbuffer", "-p"}, "run", "-it", "--name", containerName, testutil.CommonImage). + CmdOption(opts...).AssertOutContains("read detach keys") + container := base.InspectContainer(containerName) + assert.Equal(base.T, container.State.Running, true) +} + +func TestAttach(t *testing.T) { + t.Parallel() + + t.Skip("This test is very unstable and currently skipped. See https://github.com/containerd/nerdctl/issues/3558") + + skipAttachForDocker(t) + + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + + defer base.Cmd("container", "rm", "-f", containerName).AssertOK() + prepareContainerToAttach(base, containerName) + + opts := []func(*testutil.Cmd){ + testutil.WithStdin(testutil.NewDelayOnceReader(strings.NewReader("expr 1 + 1\nexit\n"))), + } + // `unbuffer -p` returns 0 even if the underlying nerdctl process returns a non-zero exit code, + // so the exit code cannot be easily tested here. + base.CmdWithHelper([]string{"unbuffer", "-p"}, "attach", containerName).CmdOption(opts...).AssertOutContains("2") + container := base.InspectContainer(containerName) + assert.Equal(base.T, container.State.Running, false) +} + +func TestAttachDetachKeys(t *testing.T) { + t.Parallel() + + skipAttachForDocker(t) + + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + + defer base.Cmd("container", "rm", "-f", containerName).AssertOK() + prepareContainerToAttach(base, containerName) + + opts := []func(*testutil.Cmd){ + testutil.WithStdin(testutil.NewDelayOnceReader(bytes.NewReader( + []byte{1, 2}, // https://www.physics.udel.edu/~watson/scen103/ascii.html + ))), + } + base.CmdWithHelper([]string{"unbuffer", "-p"}, "attach", "--detach-keys=ctrl-a,ctrl-b", containerName). + CmdOption(opts...).AssertOutContains("read detach keys") + container := base.InspectContainer(containerName) + assert.Equal(base.T, container.State.Running, true) +} + +// TestIssue3568 tests https://github.com/containerd/nerdctl/issues/3568 +func TestDetachAttachKeysForAutoRemovedContainer(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #3568 - A container should be deleted when detaching and attaching a container started with the --rm option.", + // In nerdctl the detach return code from the container is 0, but in docker the return code is 1. + // This behaviour is reported in https://github.com/containerd/nerdctl/issues/3571 so this test is skipped for Docker. + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + cmd := helpers.Command("run", "--rm", "-it", "--detach-keys=ctrl-a,ctrl-b", "--name", data.Identifier(), testutil.CommonImage) + // unbuffer(1) can be installed with `apt-get install expect`. + // + // "-p" is needed because we need unbuffer to read from stdin, and from [1]: + // "Normally, unbuffer does not read from stdin. This simplifies use of unbuffer in some situations. + // To use unbuffer in a pipeline, use the -p flag." + // + // [1] https://linux.die.net/man/1/unbuffer + cmd.WithWrapper("unbuffer", "-p") + cmd.WithStdin(testutil.NewDelayOnceReader(bytes.NewReader([]byte{1, 2}))) // https://www.physics.udel.edu/~watson/scen103/ascii.html + cmd.Run(&test.Expected{ + ExitCode: 0, + }) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("attach", data.Identifier()) + cmd.WithWrapper("unbuffer", "-p") + cmd.WithStdin(testutil.NewDelayOnceReader(strings.NewReader("exit\n"))) + return cmd + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: test.All( + func(stdout string, info string, t *testing.T) { + assert.Assert(t, !strings.Contains(helpers.Capture("ps", "-a"), data.Identifier())) + }, + ), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_commit.go b/cmd/nerdctl/container/container_commit.go similarity index 85% rename from cmd/nerdctl/container_commit.go rename to cmd/nerdctl/container/container_commit.go index ad09be63e37..e7276499ab3 100644 --- a/cmd/nerdctl/container_commit.go +++ b/cmd/nerdctl/container/container_commit.go @@ -14,20 +14,23 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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 newCommitCommand() *cobra.Command { +func NewCommitCommand() *cobra.Command { var commitCommand = &cobra.Command{ Use: "commit [flags] CONTAINER REPOSITORY[:TAG]", Short: "Create a new image from a container's changes", - Args: IsExactArgs(2), + Args: helpers.IsExactArgs(2), RunE: commitAction, ValidArgsFunction: commitShellComplete, SilenceUsage: true, @@ -41,7 +44,7 @@ func newCommitCommand() *cobra.Command { } func processCommitCommandOptions(cmd *cobra.Command) (types.ContainerCommitOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerCommitOptions{}, err } @@ -91,7 +94,7 @@ func commitAction(cmd *cobra.Command, args []string) error { func commitShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/nerdctl/container/container_commit_linux_test.go b/cmd/nerdctl/container/container_commit_linux_test.go new file mode 100644 index 00000000000..ca9bb15f9fd --- /dev/null +++ b/cmd/nerdctl/container/container_commit_linux_test.go @@ -0,0 +1,91 @@ +/* + 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 ( + "strings" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestKubeCommitSave(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.OnlyKubernetes + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + containerID := "" + // NOTE: kubectl namespaces are not the same as containerd namespaces. + // We still want kube test objects segregated in their own Kube API namespace. + nerdtest.KubeCtlCommand(helpers, "create", "namespace", "nerdctl-test-k8s").Run(&test.Expected{}) + nerdtest.KubeCtlCommand(helpers, "run", "--image", testutil.CommonImage, identifier, "--", "sleep", nerdtest.Infinity).Run(&test.Expected{}) + 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) { + containerID = strings.TrimPrefix(stdout, "containerd://") + }, + }) + data.Set("containerID", containerID) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + nerdtest.KubeCtlCommand(helpers, "delete", "pod", "--all").Run(nil) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("commit", data.Get("containerID"), "testcommitsave") + return helpers.Command("save", "testcommitsave") + } + + testCase.Expected = test.Expects(0, nil, nil) + + testCase.Run(t) + + // This below is missing configuration to allow for plain http communication + // This is left here for future work to successfully start a registry usable in the cluster + /* + // Start a registry + nerdtest.KubeCtlCommand(helpers, "run", "--port", "5000", "--image", testutil.RegistryImageStable, "testregistry"). + Run(&test.Expected{}) + + nerdtest.KubeCtlCommand(helpers, "wait", "pod", "testregistry", "--for=condition=ready", "--timeout=1m"). + AssertOK() + + 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) { + registryIP = stdout + }, + }) + + cmd = nerdtest.KubeCtlCommand(helpers, "apply", "-f", "-", fmt.Sprintf(`apiVersion: v1 + kind: ConfigMap + metadata: + name: local-registry + namespace: nerdctl-test + data: + localRegistryHosting.v1: | + host: "%s:5000" + help: "https://kind.sigs.k8s.io/docs/user/local-registry/" + `, registryIP)) + */ +} diff --git a/cmd/nerdctl/container/container_commit_test.go b/cmd/nerdctl/container/container_commit_test.go new file mode 100644 index 00000000000..22d3a3bf02c --- /dev/null +++ b/cmd/nerdctl/container/container_commit_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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestCommit(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "with pause", + Require: nerdtest.CGroup, + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("rm", "-f", identifier) + helpers.Anyhow("rmi", "-f", identifier) + }, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("exec", identifier, "sh", "-euxc", `echo hello-test-commit > /foo`) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + identifier := data.Identifier() + helpers.Ensure( + "commit", + "-c", `CMD ["/foo"]`, + "-c", `ENTRYPOINT ["cat"]`, + "--pause=true", + identifier, identifier) + return helpers.Command("run", "--rm", identifier) + }, + Expected: test.Expects(0, nil, test.Equals("hello-test-commit\n")), + }, + { + Description: "no pause", + Require: test.Not(test.Windows), + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("rm", "-f", identifier) + helpers.Anyhow("rmi", "-f", identifier) + }, + 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`) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + identifier := data.Identifier() + helpers.Ensure( + "commit", + "-c", `CMD ["/foo"]`, + "-c", `ENTRYPOINT ["cat"]`, + "--pause=false", + identifier, identifier) + return helpers.Command("run", "--rm", identifier) + }, + Expected: test.Expects(0, nil, test.Equals("hello-test-commit\n")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_cp_acid_linux_test.go b/cmd/nerdctl/container/container_cp_acid_linux_test.go new file mode 100644 index 00000000000..fc30c4ab314 --- /dev/null +++ b/cmd/nerdctl/container/container_cp_acid_linux_test.go @@ -0,0 +1,181 @@ +/* + 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" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +// This is a separate set of tests for cp specifically meant to test corner or extreme cases that do not fit in the normal testing rig +// because of their complexity + +func TestCopyAcid(t *testing.T) { + t.Parallel() + + t.Run("Travelling along volumes w/o read-only", func(t *testing.T) { + t.Parallel() + testID := testutil.Identifier(t) + tempDir := t.TempDir() + base := testutil.NewBase(t) + base.Dir = tempDir + + sourceFile := filepath.Join(tempDir, "hostfile") + sourceFileContent := []byte(testID) + + roContainer := testID + "-ro" + rwContainer := testID + "-rw" + + setup := func() { + base.Cmd("volume", "create", testID+"-1-ro").AssertOK() + base.Cmd("volume", "create", testID+"-2-rw").AssertOK() + base.Cmd("volume", "create", testID+"-3-rw").AssertOK() + base.Cmd("run", "-d", "-w", containerCwd, "--name", roContainer, "--read-only", + "-v", fmt.Sprintf("%s:%s:ro", testID+"-1-ro", "/vol1/dir1/ro"), + "-v", fmt.Sprintf("%s:%s", testID+"-2-rw", "/vol2/dir2/rw"), + testutil.CommonImage, "sleep", "Inf", + ).AssertOK() + base.Cmd("run", "-d", "-w", containerCwd, "--name", rwContainer, + "-v", fmt.Sprintf("%s:%s:ro", testID+"-1-ro", "/vol1/dir1/ro"), + "-v", fmt.Sprintf("%s:%s", testID+"-3-rw", "/vol3/dir3/rw"), + testutil.CommonImage, "sleep", "Inf", + ).AssertOK() + + base.Cmd("exec", rwContainer, "sh", "-euxc", "cd /vol3/dir3/rw; ln -s ../../../ relativelinktoroot").AssertOK() + base.Cmd("exec", rwContainer, "sh", "-euxc", "cd /vol3/dir3/rw; ln -s / absolutelinktoroot").AssertOK() + base.Cmd("exec", roContainer, "sh", "-euxc", "cd /vol2/dir2/rw; ln -s ../../../ relativelinktoroot").AssertOK() + base.Cmd("exec", roContainer, "sh", "-euxc", "cd /vol2/dir2/rw; ln -s / absolutelinktoroot").AssertOK() + // Create file on the host + err := os.WriteFile(sourceFile, sourceFileContent, filePerm) + assert.NilError(t, err) + } + + tearDown := func() { + base.Cmd("rm", "-f", roContainer).Run() + base.Cmd("rm", "-f", rwContainer).Run() + base.Cmd("volume", "rm", testID+"-1-ro").Run() + base.Cmd("volume", "rm", testID+"-2-rw").Run() + base.Cmd("volume", "rm", testID+"-3-rw").Run() + } + + t.Cleanup(tearDown) + tearDown() + + setup() + + expectedErr := containerutil.ErrTargetIsReadOnly.Error() + if testutil.GetTarget() == testutil.Docker { + expectedErr = "" + } + + t.Run("Cannot copy into a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Cannot copy into a read-only mount, in a rw container", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Can copy into a read-write mount in a read-only container", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw").Assert(icmd.Expected{ + ExitCode: 0, + }) + }) + + t.Run("Traverse read-only locations to a read-write location", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol1/dir1/ro/../../../vol2/dir2/rw").Assert(icmd.Expected{ + ExitCode: 0, + }) + }) + + t.Run("Follow an absolute symlink inside a read-write mount to a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw/absolutelinktoroot").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Follow am absolute symlink inside a read-write mount to a read-only mount", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol3/dir3/rw/absolutelinktoroot/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Follow a relative symlink inside a read-write location to a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw/relativelinktoroot").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Follow a relative symlink inside a read-write location to a read-only mount", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol3/dir3/rw/relativelinktoroot/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + t.Run("Cannot copy into a HOST read-only location", func(t *testing.T) { + t.Parallel() + + // Root will just ignore the 000 permission on the host directory. + if !rootlessutil.IsRootless() { + t.Skip("This test does not work rootful") + } + + err := os.MkdirAll(filepath.Join(tempDir, "rotest"), 0o000) + assert.NilError(t, err) + base.Cmd("cp", roContainer+":/etc/issue", filepath.Join(tempDir, "rotest")).Assert(icmd.Expected{ + ExitCode: 1, + Err: expectedErr, + }) + }) + + }) +} diff --git a/pkg/cmd/container/run_cgroup_windows.go b/cmd/nerdctl/container/container_cp_freebsd.go similarity index 73% rename from pkg/cmd/container/run_cgroup_windows.go rename to cmd/nerdctl/container/container_cp_freebsd.go index 7ae22379ef9..4e7d2cfd518 100644 --- a/pkg/cmd/container/run_cgroup_windows.go +++ b/cmd/nerdctl/container/container_cp_freebsd.go @@ -16,11 +16,8 @@ package container -import ( - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" -) +import "github.com/spf13/cobra" -func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci.SpecOpts, error) { - return []oci.SpecOpts{}, nil +func AddCpCommand(rootCmd *cobra.Command) { + // NOP } diff --git a/cmd/nerdctl/container_cp_linux.go b/cmd/nerdctl/container/container_cp_linux.go similarity index 61% rename from cmd/nerdctl/container_cp_linux.go rename to cmd/nerdctl/container/container_cp_linux.go index dc412ad3257..e9af6a7282d 100644 --- a/cmd/nerdctl/container_cp_linux.go +++ b/cmd/nerdctl/container/container_cp_linux.go @@ -14,23 +14,24 @@ limitations under the License. */ -package main +package container import ( - "encoding/json" + "errors" "fmt" - "os" - "os/exec" + "path/filepath" + "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" "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/container" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func newCpCommand() *cobra.Command { - shortHelp := "Copy files/folders between a running container and the local filesystem." longHelp := shortHelp + ` @@ -46,7 +47,7 @@ Using 'nerdctl cp' with untrusted or malicious containers is unsupported and may nerdctl cp [flags] SRC_PATH|- CONTAINER:DEST_PATH` var cpCommand = &cobra.Command{ Use: usage, - Args: IsExactArgs(2), + Args: helpers.IsExactArgs(2), Short: shortHelp, Long: longHelp, RunE: cpAction, @@ -65,11 +66,26 @@ func cpAction(cmd *cobra.Command, args []string) error { if err != nil { return err } + if rootlessutil.IsRootless() { + options.GOptions.Address, err = rootlessutil.RootlessContainredSockAddress() + 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 container.Cp(cmd.Context(), options) + return container.Cp(ctx, client, options) } func processCpOptions(cmd *cobra.Command, args []string) (types.ContainerCpOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ContainerCpOptions{}, err + } flagL, err := cmd.Flags().GetBool("follow-link") if err != nil { return types.ContainerCpOptions{}, err @@ -99,40 +115,65 @@ func processCpOptions(cmd *cobra.Command, args []string) (types.ContainerCpOptio } container2host := srcSpec.Container != nil - var container string + var containerReq string if container2host { - container = *srcSpec.Container + containerReq = *srcSpec.Container } else { - container = *destSpec.Container + containerReq = *destSpec.Container } - ctx := cmd.Context() + return types.ContainerCpOptions{ + GOptions: globalOptions, + Container2Host: container2host, + ContainerReq: containerReq, + DestPath: destSpec.Path, + SrcPath: srcSpec.Path, + FollowSymLink: flagL, + }, nil +} + +func AddCpCommand(rootCmd *cobra.Command) { + rootCmd.AddCommand(newCpCommand()) +} - // cp works in the host namespace (for inspecting file permissions), so we can't directly use the Go client. +var errFileSpecDoesntMatchFormat = errors.New("filespec must match the canonical format: [container:]file/path") - selfExe, inspectArgs := globalFlags(cmd) - inspectArgs = append(inspectArgs, "container", "inspect", "--mode=native", "--format={{json .Process}}", container) - inspectCmd := exec.CommandContext(ctx, selfExe, inspectArgs...) - inspectCmd.Stderr = os.Stderr - inspectOut, err := inspectCmd.Output() - if err != nil { - return types.ContainerCpOptions{}, fmt.Errorf("failed to execute %v: %w", inspectCmd.Args, err) - } - var proc native.Process - if err := json.Unmarshal(inspectOut, &proc); err != nil { - return types.ContainerCpOptions{}, err +func parseCpFileSpec(arg string) (*cpFileSpec, error) { + i := strings.Index(arg, ":") + + // filespec starting with a semicolon is invalid + if i == 0 { + return nil, errFileSpecDoesntMatchFormat } - if proc.Status.Status != containerd.Running { - return types.ContainerCpOptions{}, fmt.Errorf("expected container status %v, got %v", containerd.Running, proc.Status.Status) + + if filepath.IsAbs(arg) { + // Explicit local absolute path, e.g., `C:\foo` or `/foo`. + return &cpFileSpec{ + Container: nil, + Path: arg, + }, nil } - if proc.Pid <= 0 { - return types.ContainerCpOptions{}, fmt.Errorf("got non-positive PID %v", proc.Pid) + + parts := strings.SplitN(arg, ":", 2) + + if len(parts) == 1 || strings.HasPrefix(parts[0], ".") { + // Either there's no `:` in the arg + // OR it's an explicit local relative path like `./file:name.txt`. + return &cpFileSpec{ + Path: arg, + }, nil } - return types.ContainerCpOptions{ - Container2Host: container2host, - Pid: proc.Pid, - DestPath: destSpec.Path, - SrcPath: srcSpec.Path, - FollowSymLink: flagL, + return &cpFileSpec{ + Container: &parts[0], + Path: parts[1], }, nil } + +type cpFileSpec struct { + Container *string + Path string +} + +func cpShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterFileExt +} diff --git a/cmd/nerdctl/container/container_cp_linux_test.go b/cmd/nerdctl/container/container_cp_linux_test.go new file mode 100644 index 00000000000..a584b722243 --- /dev/null +++ b/cmd/nerdctl/container/container_cp_linux_test.go @@ -0,0 +1,957 @@ +/* + 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" + "path/filepath" + "strings" + "syscall" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +// For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ +// Obviously, none of this is fully windows ready - obviously `nerdctl cp` itself is not either, so, ok for now. +const ( + // Use this to poke the testing rig for improper path handling + // TODO: fuzz this more seriously + // FIXME: the following will break the test (anything that will evaluate on the shell, obviously): + // - ` + // - $a, ${a}, etc + complexify = "" // = "-~a0-_.(){}[]*#! \"'∞" + + pathDoesNotExistRelative = "does-not-exist" + complexify + pathDoesNotExistAbsolute = string(os.PathSeparator) + "does-not-exist" + complexify + pathIsAFileRelative = "is-a-file" + complexify + pathIsAFileAbsolute = string(os.PathSeparator) + "is-a-file" + complexify + pathIsADirRelative = "is-a-dir" + complexify + pathIsADirAbsolute = string(os.PathSeparator) + "is-a-dir" + complexify + pathIsAVolumeMount = string(os.PathSeparator) + "is-a-volume-mount" + complexify + + srcFileName = "test-file" + complexify + + // Since nerdctl cp must NOT obey container wd, but instead resolve paths against the root, we set this + // explicitly to ensure we do the right thing wrt that. + containerCwd = "/nerdctl/cp/test" + + dirPerm = 0o755 + filePerm = 0o644 +) + +var srcDirName = filepath.Join("three-levels-src-dir", "test-dir", "dir"+complexify) + +type testgroup struct { + description string // parent test description + toContainer bool // copying to, or from container + + // sourceSpec as specified by the user (without the container: part) - can be relative or absolute - + // if sourceSpec points to a file, you must use srcFileName for filename + sourceSpec string + sourceIsAFile bool // whether the provided sourceSpec points to a file or a dir + testCases []testcases // testcases +} + +type testcases struct { + description string // textual description of what the test is doing + destinationSpec string // destination path as specified by the user (without the container: part) - can be relative or absolute + expect icmd.Expected // expectation + + // Optional + catFile string // path that we "cat" - defaults to destinationSpec if not specified + setup func(base *testutil.Base, container string, destPath string) // additional test setup if needed + tearDown func() // additional cleanup if needed + volume func(base *testutil.Base, id string) (string, string, bool) // volume creation function if needed (should return the volume name, mountPoint, readonly flag) +} + +func TestCopyToContainer(t *testing.T) { + t.Parallel() + + testGroups := []*testgroup{ + { + description: "Copying to container, SRC_PATH is a file, absolute", + sourceSpec: filepath.Join(string(os.PathSeparator), srcDirName, srcFileName), + sourceIsAFile: true, + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, relative, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + // FIXME: it is unclear why the code path with absolute (this test) versus relative (just above) + // yields a different error. Both should ideally be ErrCannotCopyDirToFile + // This is probably happening somewhere in resolve. + // This is not a deal killer, as both DO error with a reasonable explanation, but a bit + // frustrating + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a volume mount-point", + destinationSpec: pathIsAVolumeMount, + catFile: filepath.Join(pathIsAVolumeMount, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + // FIXME the way we handle volume is not right - too complicated for the test author + volume: func(base *testutil.Base, id string) (string, string, bool) { + base.Cmd("volume", "create", id).Run() + return id, pathIsAVolumeMount, false + }, + }, + { + description: "DEST_PATH is a read-only volume mount-point", + destinationSpec: pathIsAVolumeMount, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }, + volume: func(base *testutil.Base, id string) (string, string, bool) { + base.Cmd("volume", "create", id).Run() + return id, pathIsAVolumeMount, true + }, + }, + }, + }, + { + description: "Copying to container, SRC_PATH is a directory", + sourceSpec: srcDirName, + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, relative, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + // FIXME: it is unclear why the code path with absolute (this test) versus relative (just above) + // yields a different error. Both should ideally be ErrCannotCopyDirToFile + // This is probably happening somewhere in resolve. + // This is not a deal killer, as both DO error with a reasonable explanation, but a bit + // frustrating + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + }, + }, + { + description: "Copying to container, SRC_PATH is a directory ending with /.", + sourceSpec: srcDirName + string(os.PathSeparator) + ".", + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + }, + }, + } + + for _, tg := range testGroups { + cpTestHelper(t, tg) + } +} + +func TestCopyFromContainer(t *testing.T) { + t.Parallel() + + testGroups := []*testgroup{ + { + description: "Copying from container, SRC_PATH specifies a file", + sourceSpec: srcFileName, + sourceIsAFile: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with a path separator", + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with a path separator", + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, relative, improperly ends with a separator", + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute, improperly ends with a separator", + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative, ending with a path separator", + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute, ending with a path separator", + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + { + description: "Copying from container, SRC_PATH specifies a dir", + sourceSpec: srcDirName, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, ends with path separator", + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute, ends with path separator", + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, relative, improperly ends with path separator", + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute, improperly ends with path separator", + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with path separator", + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with path separator", + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + + { + description: "SRC_PATH is a dir, with a trailing slash/dot", + sourceSpec: srcDirName + string(os.PathSeparator) + ".", + testCases: []testcases{ + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + } + + for _, tg := range testGroups { + cpTestHelper(t, tg) + } +} + +func assertCatHelper(base *testutil.Base, catPath string, fileContent []byte, container string, expectedUID int, containerIsStopped bool) { + base.T.Logf("catPath=%q", catPath) + if container != "" && containerIsStopped { + base.Cmd("start", container).AssertOK() + defer base.Cmd("stop", container).AssertOK() + } + + if container == "" { + got, err := os.ReadFile(catPath) + assert.NilError(base.T, err, "Failed reading from file") + assert.DeepEqual(base.T, fileContent, got) + st, err := os.Stat(catPath) + assert.NilError(base.T, err) + stSys := st.Sys().(*syscall.Stat_t) + expected := uint32(expectedUID) + actual := stSys.Uid + assert.DeepEqual(base.T, expected, actual) + } else { + base.Cmd("exec", container, "sh", "-c", "--", fmt.Sprintf("ls -lA /; echo %q; cat %q", catPath, catPath)).AssertOutContains(string(fileContent)) + base.Cmd("exec", container, "stat", "-c", "%u", catPath).AssertOutExactly(fmt.Sprintf("%d\n", expectedUID)) + } +} + +func cpTestHelper(t *testing.T, tg *testgroup) { + // Get the source path + groupSourceSpec := tg.sourceSpec + groupSourceDir := groupSourceSpec + if tg.sourceIsAFile { + groupSourceDir = filepath.Dir(groupSourceSpec) + } + + // Copy direction + copyToContainer := tg.toContainer + // Description + description := tg.description + // Test cases + testCases := tg.testCases + + // Compute UIDs dependent on cp direction + var srcUID, destUID int + if copyToContainer { + srcUID = os.Geteuid() + destUID = srcUID + } else { + srcUID = 42 + destUID = os.Geteuid() + } + + t.Run(description, func(t *testing.T) { + t.Parallel() + + for _, tc := range testCases { + testCase := tc + + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + // Compute test-specific values + testID := testutil.Identifier(t) + containerRunning := testID + "-r" + containerStopped := testID + "-s" + sourceFileContent := []byte(testID) + tempDir := t.TempDir() + + base := testutil.NewBase(t) + // Change working directory for commands to execute to the newly created temp directory on the host + // Note that ChDir won't do in a parallel context - and that setup func on the host below + // has to deal with that problem separately by making sure relative paths are resolved against temp + base.Dir = tempDir + + // Prepare the specs and derived variables + sourceSpec := groupSourceSpec + destinationSpec := testCase.destinationSpec + + // If the test case does not specify a catFile, start with the destination spec + catFile := testCase.catFile + if catFile == "" { + catFile = destinationSpec + } + + sourceFile := filepath.Join(groupSourceDir, srcFileName) + if copyToContainer { + // Use an absolute path for evaluation + if !filepath.IsAbs(catFile) { + catFile = filepath.Join(string(os.PathSeparator), catFile) + } + // If the sourceFile is still relative, make it absolute to the temp + sourceFile = filepath.Join(tempDir, sourceFile) + // If the spec path for source on the host was absolute, make sure we put that under tempDir + if filepath.IsAbs(sourceSpec) { + sourceSpec = tempDir + sourceSpec + } + } else { + // If we are copying to host, we need to make sure we have an absolute path to cat, relative to temp, + // whether it is relative, or "absolute" + catFile = filepath.Join(tempDir, catFile) + // If the spec for destination on the host was absolute, make sure we put that under tempDir + if filepath.IsAbs(destinationSpec) { + destinationSpec = tempDir + destinationSpec + } + } + + // Teardown: clean-up containers and optional volume + tearDown := func() { + base.Cmd("rm", "-f", containerRunning).Run() + base.Cmd("rm", "-f", containerStopped).Run() + if testCase.volume != nil { + volID, _, _ := testCase.volume(base, testID) + base.Cmd("volume", "rm", volID).Run() + } + } + + createFileOnHost := func() { + // Create file on the host + err := os.MkdirAll(filepath.Dir(sourceFile), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(sourceFile, sourceFileContent, filePerm) + assert.NilError(t, err) + } + + // Setup: create volume, containers, create the source file + setup := func() { + args := []string{"run", "-d", "-w", containerCwd} + if testCase.volume != nil { + vol, mount, ro := testCase.volume(base, testID) + volArg := fmt.Sprintf("%s:%s", vol, mount) + if ro { + volArg += ":ro" + } + args = append(args, "-v", volArg) + } + base.Cmd(append(args, "--name", containerRunning, testutil.CommonImage, "sleep", "Inf")...).AssertOK() + base.Cmd(append(args, "--name", containerStopped, testutil.CommonImage, "sleep", "Inf")...).AssertOK() + + if copyToContainer { + createFileOnHost() + } else { + // Create file content in the container + // Note: cd /, otherwise we end-up in the container cwd, which is NOT obeyed by cp + mkSrcScript := fmt.Sprintf("cd /; mkdir -p %q && echo -n %q >%q && chown %d %q", filepath.Dir(sourceFile), sourceFileContent, sourceFile, srcUID, sourceFile) + base.Cmd("exec", containerRunning, "sh", "-euc", mkSrcScript).AssertOK() + base.Cmd("exec", containerStopped, "sh", "-euc", mkSrcScript).AssertOK() + } + + // If we have optional setup, run that now + if testCase.setup != nil { + // Some specs may come with a trailing slash (proper or improper) + // Setup should still work in all cases (including if its a file), and get through to the actual test + setupDest := destinationSpec + setupDest = strings.TrimSuffix(setupDest, string(os.PathSeparator)) + if !filepath.IsAbs(setupDest) { + if copyToContainer { + setupDest = filepath.Join(string(os.PathSeparator), setupDest) + } else { + setupDest = filepath.Join(tempDir, setupDest) + } + } + testCase.setup(base, containerRunning, setupDest) + testCase.setup(base, containerStopped, setupDest) + } + + // Stop the "stopped" container + base.Cmd("stop", containerStopped).AssertOK() + } + + tearDown() + t.Cleanup(tearDown) + // If we have custom teardown, do that + if testCase.tearDown != nil { + testCase.tearDown() + t.Cleanup(testCase.tearDown) + } + + // Do the setup + setup() + + // If Docker, removes the err part of expectation + if testutil.GetTarget() == testutil.Docker { + testCase.expect.Err = "" + } + + // Build the final src and dest specifiers, including `containerXYZ:` + container := "" + if copyToContainer { + container = containerRunning + base.Cmd("cp", sourceSpec, containerRunning+":"+destinationSpec).Assert(testCase.expect) + } else { + base.Cmd("cp", containerRunning+":"+sourceSpec, destinationSpec).Assert(testCase.expect) + } + + // Run the actual test for the running container + // If we expect the op to be a success, also check the destination file + if testCase.expect.ExitCode == 0 { + assertCatHelper(base, catFile, sourceFileContent, container, destUID, false) + } + + // When copying container > host, we get shadowing from the previous container, possibly hiding failures + // Solution: clear-up the tempDir + if copyToContainer { + err := os.RemoveAll(tempDir) + assert.NilError(t, err) + err = os.MkdirAll(tempDir, dirPerm) + assert.NilError(t, err) + createFileOnHost() + defer os.RemoveAll(tempDir) + } + + // ... and for the stopped container + container = "" + var cmd *testutil.Cmd + if copyToContainer { + container = containerStopped + cmd = base.Cmd("cp", sourceSpec, containerStopped+":"+destinationSpec) + } else { + cmd = base.Cmd("cp", containerStopped+":"+sourceSpec, destinationSpec) + } + + if rootlessutil.IsRootless() && testutil.GetTarget() == testutil.Nerdctl { + cmd.Assert( + icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrRootlessCannotCp.Error(), + }) + return + } + + cmd.Assert(testCase.expect) + if testCase.expect.ExitCode == 0 { + assertCatHelper(base, catFile, sourceFileContent, container, destUID, true) + } + }) + } + }) +} diff --git a/cmd/nerdctl/container/container_cp_windows.go b/cmd/nerdctl/container/container_cp_windows.go new file mode 100644 index 00000000000..4e7d2cfd518 --- /dev/null +++ b/cmd/nerdctl/container/container_cp_windows.go @@ -0,0 +1,23 @@ +/* + 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 "github.com/spf13/cobra" + +func AddCpCommand(rootCmd *cobra.Command) { + // NOP +} diff --git a/cmd/nerdctl/container_create.go b/cmd/nerdctl/container/container_create.go similarity index 81% rename from cmd/nerdctl/container_create.go rename to cmd/nerdctl/container/container_create.go index 27280f28c99..45726e7f323 100644 --- a/cmd/nerdctl/container_create.go +++ b/cmd/nerdctl/container/container_create.go @@ -14,20 +14,22 @@ limitations under the License. */ -package main +package container import ( "fmt" "runtime" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/containerutil" "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/container" + "github.com/containerd/nerdctl/v2/pkg/containerutil" ) -func newCreateCommand() *cobra.Command { +func NewCreateCommand() *cobra.Command { shortHelp := "Create a new container. Optionally specify \"ipfs://\" or \"ipns://\" scheme to pull image from IPFS." longHelp := shortHelp switch runtime.GOOS { @@ -53,69 +55,73 @@ func newCreateCommand() *cobra.Command { return createCommand } -func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreateOptions, err error) { - opt.Stdout = cmd.OutOrStdout() - opt.Stderr = cmd.ErrOrStderr() - opt.GOptions, err = processRootCmdFlags(cmd) +func processContainerCreateOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) { + var err error + opt := types.ContainerCreateOptions{ + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + } + + opt.GOptions, err = helpers.ProcessRootCmdFlags(cmd) if err != nil { - return + return opt, err } - opt.NerdctlCmd, opt.NerdctlArgs = globalFlags(cmd) + opt.NerdctlCmd, opt.NerdctlArgs = helpers.GlobalFlags(cmd) // #region for basic flags // The command `container start` doesn't support the flag `--interactive`. Set the default value of `opt.Interactive` false. opt.Interactive = false opt.TTY, err = cmd.Flags().GetBool("tty") if err != nil { - return + return opt, err } // The nerdctl create command similar to nerdctl run -d except the container is never started. // So we keep the default value of `opt.Detach` true. opt.Detach = true opt.Restart, err = cmd.Flags().GetString("restart") if err != nil { - return + return opt, err } opt.Rm, err = cmd.Flags().GetBool("rm") if err != nil { - return + return opt, err } opt.Pull, err = cmd.Flags().GetString("pull") if err != nil { - return + return opt, err } opt.Pid, err = cmd.Flags().GetString("pid") if err != nil { - return + return opt, err } opt.StopSignal, err = cmd.Flags().GetString("stop-signal") if err != nil { - return + return opt, err } opt.StopTimeout, err = cmd.Flags().GetInt("stop-timeout") if err != nil { - return + return opt, err } // #endregion // #region for platform flags opt.Platform, err = cmd.Flags().GetString("platform") if err != nil { - return + return opt, err } // #endregion // #region for init process flags opt.InitProcessFlag, err = cmd.Flags().GetBool("init") if err != nil { - return + return opt, err } if opt.InitProcessFlag || cmd.Flags().Changed("init-binary") { var initBinary string initBinary, err = cmd.Flags().GetString("init-binary") if err != nil { - return + return opt, err } opt.InitBinary = &initBinary } @@ -124,97 +130,97 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat // #region for isolation flags opt.Isolation, err = cmd.Flags().GetString("isolation") if err != nil { - return + return opt, err } // #endregion // #region for resource flags opt.CPUs, err = cmd.Flags().GetFloat64("cpus") if err != nil { - return + return opt, err } opt.CPUQuota, err = cmd.Flags().GetInt64("cpu-quota") if err != nil { - return + return opt, err } opt.CPUPeriod, err = cmd.Flags().GetUint64("cpu-period") if err != nil { - return + return opt, err } opt.CPUShares, err = cmd.Flags().GetUint64("cpu-shares") if err != nil { - return + return opt, err } opt.CPUSetCPUs, err = cmd.Flags().GetString("cpuset-cpus") if err != nil { - return + return opt, err } opt.CPUSetMems, err = cmd.Flags().GetString("cpuset-mems") if err != nil { - return + return opt, err } opt.Memory, err = cmd.Flags().GetString("memory") if err != nil { - return + return opt, err } opt.MemoryReservationChanged = cmd.Flags().Changed("memory-reservation") opt.MemoryReservation, err = cmd.Flags().GetString("memory-reservation") if err != nil { - return + return opt, err } opt.MemorySwap, err = cmd.Flags().GetString("memory-swap") if err != nil { - return + return opt, err } opt.MemorySwappiness64Changed = cmd.Flags().Changed("memory-swappiness") opt.MemorySwappiness64, err = cmd.Flags().GetInt64("memory-swappiness") if err != nil { - return + return opt, err } opt.KernelMemoryChanged = cmd.Flag("kernel-memory").Changed opt.KernelMemory, err = cmd.Flags().GetString("kernel-memory") if err != nil { - return + return opt, err } opt.OomKillDisable, err = cmd.Flags().GetBool("oom-kill-disable") if err != nil { - return + return opt, err } opt.OomScoreAdjChanged = cmd.Flags().Changed("oom-score-adj") opt.OomScoreAdj, err = cmd.Flags().GetInt("oom-score-adj") if err != nil { - return + return opt, err } opt.PidsLimit, err = cmd.Flags().GetInt64("pids-limit") if err != nil { - return + return opt, err } opt.CgroupConf, err = cmd.Flags().GetStringSlice("cgroup-conf") if err != nil { - return + return opt, err } opt.BlkioWeight, err = cmd.Flags().GetUint16("blkio-weight") if err != nil { - return + return opt, err } opt.Cgroupns, err = cmd.Flags().GetString("cgroupns") if err != nil { - return + return opt, err } opt.CgroupParent, err = cmd.Flags().GetString("cgroup-parent") if err != nil { - return + return opt, err } opt.Device, err = cmd.Flags().GetStringSlice("device") if err != nil { - return + return opt, err } // #endregion // #region for intel RDT flags opt.RDTClass, err = cmd.Flags().GetString("rdt-class") if err != nil { - return + return opt, err } // #endregion @@ -223,75 +229,83 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat // Otherwise we will inherit permissions from the user that the containerd process is running as opt.User, err = cmd.Flags().GetString("user") if err != nil { - return + return opt, err } opt.Umask = "" if cmd.Flags().Changed("umask") { opt.Umask, err = cmd.Flags().GetString("umask") if err != nil { - return + return opt, err } } opt.GroupAdd, err = cmd.Flags().GetStringSlice("group-add") if err != nil { - return + return opt, err } // #endregion // #region for security flags opt.SecurityOpt, err = cmd.Flags().GetStringArray("security-opt") if err != nil { - return + return opt, err } opt.CapAdd, err = cmd.Flags().GetStringSlice("cap-add") if err != nil { - return + return opt, err } opt.CapDrop, err = cmd.Flags().GetStringSlice("cap-drop") if err != nil { - return + return opt, err } opt.Privileged, err = cmd.Flags().GetBool("privileged") if err != nil { - return + return opt, err + } + opt.Systemd, err = cmd.Flags().GetString("systemd") + if err != nil { + return opt, err } // #endregion // #region for runtime flags opt.Runtime, err = cmd.Flags().GetString("runtime") if err != nil { - return + return opt, err } opt.Sysctl, err = cmd.Flags().GetStringArray("sysctl") if err != nil { - return + return opt, err } // #endregion // #region for volume flags opt.Volume, err = cmd.Flags().GetStringArray("volume") if err != nil { - return + return opt, err } // tmpfs needs to be StringArray, not StringSlice, to prevent "/foo:size=64m,exec" from being split to {"/foo:size=64m", "exec"} opt.Tmpfs, err = cmd.Flags().GetStringArray("tmpfs") if err != nil { - return + return opt, err } opt.Mount, err = cmd.Flags().GetStringArray("mount") if err != nil { - return + return opt, err + } + opt.VolumesFrom, err = cmd.Flags().GetStringArray("volumes-from") + if err != nil { + return opt, err } // #endregion // #region for rootfs flags opt.ReadOnly, err = cmd.Flags().GetBool("read-only") if err != nil { - return + return opt, err } opt.Rootfs, err = cmd.Flags().GetBool("rootfs") if err != nil { - return + return opt, err } // #endregion @@ -299,19 +313,19 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat opt.EntrypointChanged = cmd.Flags().Changed("entrypoint") opt.Entrypoint, err = cmd.Flags().GetStringArray("entrypoint") if err != nil { - return + return opt, err } opt.Workdir, err = cmd.Flags().GetString("workdir") if err != nil { - return + return opt, err } opt.Env, err = cmd.Flags().GetStringArray("env") if err != nil { - return + return opt, err } opt.EnvFile, err = cmd.Flags().GetStringSlice("env-file") if err != nil { - return + return opt, err } // #endregion @@ -319,25 +333,29 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat opt.NameChanged = cmd.Flags().Changed("name") opt.Name, err = cmd.Flags().GetString("name") if err != nil { - return + return opt, err } opt.Label, err = cmd.Flags().GetStringArray("label") if err != nil { - return + return opt, err } opt.LabelFile, err = cmd.Flags().GetStringSlice("label-file") if err != nil { - return + return opt, err + } + opt.Annotations, err = cmd.Flags().GetStringArray("annotation") + if err != nil { + return opt, err } opt.CidFile, err = cmd.Flags().GetString("cidfile") if err != nil { - return + return opt, err } opt.PidFile = "" if cmd.Flags().Changed("pidfile") { opt.PidFile, err = cmd.Flags().GetString("pidfile") if err != nil { - return + return opt, err } } // #endregion @@ -346,50 +364,54 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat // json-file is the built-in and default log driver for nerdctl opt.LogDriver, err = cmd.Flags().GetString("log-driver") if err != nil { - return + return opt, err } opt.LogOpt, err = cmd.Flags().GetStringArray("log-opt") if err != nil { - return + return opt, err } // #endregion // #region for shared memory flags opt.IPC, err = cmd.Flags().GetString("ipc") if err != nil { - return + return opt, err } opt.ShmSize, err = cmd.Flags().GetString("shm-size") if err != nil { - return + return opt, err } // #endregion // #region for gpu flags opt.GPUs, err = cmd.Flags().GetStringArray("gpus") if err != nil { - return + return opt, err } // #endregion // #region for ulimit flags opt.Ulimit, err = cmd.Flags().GetStringSlice("ulimit") if err != nil { - return + return opt, err } // #endregion // #region for ipfs flags opt.IPFSAddress, err = cmd.Flags().GetString("ipfs-address") if err != nil { - return + return opt, err } // #endregion // #region for image pull and verify options - imageVerifyOpt, err := processImageVerifyOptions(cmd) + imageVerifyOpt, err := helpers.ProcessImageVerifyOptions(cmd) + if err != nil { + return opt, err + } + quiet, err := cmd.Flags().GetBool("quiet") if err != nil { - return + return opt, err } opt.ImagePullOpt = types.ImagePullOptions{ GOptions: opt.GOptions, @@ -397,6 +419,7 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat IPFSAddress: opt.IPFSAddress, Stdout: opt.Stdout, Stderr: opt.Stderr, + Quiet: quiet, } // #endregion @@ -423,7 +446,7 @@ func createAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load networking flags: %s", err) } - netManager, err := containerutil.NewNetworkingOptionsManager(createOpt.GOptions, netFlags) + netManager, err := containerutil.NewNetworkingOptionsManager(createOpt.GOptions, netFlags, client) if err != nil { return err } diff --git a/cmd/nerdctl/container/container_create_linux_test.go b/cmd/nerdctl/container/container_create_linux_test.go new file mode 100644 index 00000000000..a4b3bc64fa2 --- /dev/null +++ b/cmd/nerdctl/container/container_create_linux_test.go @@ -0,0 +1,326 @@ +/* + 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 ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + "gotest.tools/v3/assert" + + "github.com/containerd/containerd/v2/defaults" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "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/test" +) + +func TestCreateWithLabel(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + tID := testutil.Identifier(t) + + base.Cmd("create", "--name", tID, "--label", "foo=bar", testutil.NginxAlpineImage, "echo", "foo").AssertOK() + defer base.Cmd("rm", "-f", tID).Run() + inspect := base.InspectContainer(tID) + assert.Equal(base.T, "bar", inspect.Config.Labels["foo"]) + // the label `maintainer`` is defined by image + assert.Equal(base.T, "NGINX Docker Maintainers ", inspect.Config.Labels["maintainer"]) +} + +func TestCreateWithMACAddress(t *testing.T) { + base := testutil.NewBase(t) + tID := testutil.Identifier(t) + networkBridge := "testNetworkBridge" + tID + networkMACvlan := "testNetworkMACvlan" + tID + networkIPvlan := "testNetworkIPvlan" + tID + + tearDown := func() { + base.Cmd("network", "rm", networkBridge).Run() + base.Cmd("network", "rm", networkMACvlan).Run() + base.Cmd("network", "rm", networkIPvlan).Run() + } + + tearDown() + t.Cleanup(tearDown) + + base.Cmd("network", "create", networkBridge, "--driver", "bridge").AssertOK() + base.Cmd("network", "create", networkMACvlan, "--driver", "macvlan").AssertOK() + base.Cmd("network", "create", networkIPvlan, "--driver", "ipvlan").AssertOK() + + defaultMac := base.Cmd("run", "--rm", "-i", "--network", "host", testutil.CommonImage). + CmdOption(testutil.WithStdin(strings.NewReader("ip addr show eth0 | grep ether | awk '{printf $2}'"))). + Run().Stdout() + + passedMac := "we expect the generated mac on the output" + tests := []struct { + Network string + WantErr bool + Expect string + }{ + {"host", false, defaultMac}, // anything but the actual address being passed + {"none", false, ""}, + {"container:whatever" + tID, true, "container"}, // "No such container" vs. "could not find container" + {"bridge", false, passedMac}, + {networkBridge, false, passedMac}, + {networkMACvlan, false, passedMac}, + {networkIPvlan, true, "not support"}, + } + for i, test := range tests { + containerName := fmt.Sprintf("%s_%d", tID, i) + testName := fmt.Sprintf("%s_container:%s_network:%s_expect:%s", tID, containerName, test.Network, test.Expect) + expect := test.Expect + network := test.Network + wantErr := test.WantErr + t.Run(testName, func(tt *testing.T) { + tt.Parallel() + + macAddress, err := nettestutil.GenerateMACAddress() + if err != nil { + tt.Errorf("failed to generate MAC address: %s", err) + } + if expect == passedMac { + expect = macAddress + } + tearDown := func() { + base.Cmd("rm", "-f", containerName).Run() + } + tearDown() + tt.Cleanup(tearDown) + // This is currently blocked by https://github.com/containerd/nerdctl/pull/3104 + // res := base.Cmd("create", "-i", "--network", network, "--mac-address", macAddress, testutil.CommonImage).Run() + res := base.Cmd("create", "--network", network, "--name", containerName, + "--mac-address", macAddress, testutil.CommonImage, + "sh", "-c", "--", "ip addr show").Run() + + if !wantErr { + assert.Assert(t, res.ExitCode == 0, "Command should have succeeded", res) + // This is currently blocked by: https://github.com/containerd/nerdctl/pull/3104 + // res = base.Cmd("start", "-i", containerName). + // CmdOption(testutil.WithStdin(strings.NewReader("ip addr show eth0 | grep ether | awk '{printf $2}'"))).Run() + res = base.Cmd("start", "-a", containerName).Run() + // FIXME: flaky - this has failed on the CI once, with the output NOT containing anything + // https://github.com/containerd/nerdctl/actions/runs/11392051487/job/31697214002?pr=3535#step:7:271 + assert.Assert(t, strings.Contains(res.Stdout(), expect), fmt.Sprintf("expected output to contain %q: %q", expect, res.Stdout())) + assert.Assert(t, res.ExitCode == 0, "Command should have succeeded") + } else { + if testutil.GetTarget() == testutil.Docker && + (network == networkIPvlan || network == "container:whatever"+tID) { + // unlike nerdctl + // when using network ipvlan or container in Docker + // it delays fail on executing start command + assert.Assert(t, res.ExitCode == 0, "Command should have succeeded", res) + res = base.Cmd("start", "-i", "-a", containerName). + CmdOption(testutil.WithStdin(strings.NewReader("ip addr show eth0 | grep ether | awk '{printf $2}'"))).Run() + } + + // See https://github.com/containerd/nerdctl/issues/3101 + if testutil.GetTarget() == testutil.Docker && + (network == networkBridge) { + expect = "" + } + if expect != "" { + assert.Assert(t, strings.Contains(res.Combined(), expect), fmt.Sprintf("expected output to contain %q: %q", expect, res.Combined())) + } else { + assert.Assert(t, res.Combined() == "", fmt.Sprintf("expected output to be empty: %q", res.Combined())) + } + assert.Assert(t, res.ExitCode != 0, "Command should have failed", res) + } + }) + } +} + +func TestCreateWithTty(t *testing.T) { + base := testutil.NewBase(t) + imageName := testutil.CommonImage + withoutTtyContainerName := "without-terminal-" + testutil.Identifier(t) + withTtyContainerName := "with-terminal-" + testutil.Identifier(t) + + // without -t, fail + base.Cmd("create", "--name", withoutTtyContainerName, imageName, "stty").AssertOK() + base.Cmd("start", withoutTtyContainerName).AssertOK() + defer base.Cmd("container", "rm", "-f", withoutTtyContainerName).AssertOK() + base.Cmd("logs", withoutTtyContainerName).AssertCombinedOutContains("stty: standard input: Not a tty") + withoutTtyContainer := base.InspectContainer(withoutTtyContainerName) + assert.Equal(base.T, 1, withoutTtyContainer.State.ExitCode) + + // with -t, success + base.Cmd("create", "-t", "--name", withTtyContainerName, imageName, "stty").AssertOK() + base.Cmd("start", withTtyContainerName).AssertOK() + defer base.Cmd("container", "rm", "-f", withTtyContainerName).AssertOK() + base.Cmd("logs", withTtyContainerName).AssertCombinedOutContains("speed 38400 baud; line = 0;") + withTtyContainer := base.InspectContainer(withTtyContainerName) + assert.Equal(base.T, 0, withTtyContainer.State.ExitCode) +} + +// TestIssue2993 tests https://github.com/containerd/nerdctl/issues/2993 +func TestIssue2993(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = test.Not(nerdtest.Docker) + + const ( + containersPathKey = "containersPath" + etchostsPathKey = "etchostsPath" + ) + + getAddrHash := func(addr string) string { + const addrHashLen = 8 + + d := digest.SHA256.FromString(addr) + h := d.Encoded()[0:addrHashLen] + + return h + } + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #2993 - nerdctl no longer leaks containers and etchosts directories and files when container creation fails.", + Setup: func(data test.Data, helpers test.Helpers) { + dataRoot := data.TempDir() + + helpers.Ensure("run", "--data-root", dataRoot, "--name", data.Identifier(), "-d", testutil.AlpineImage, "sleep", nerdtest.Infinity) + + h := getAddrHash(defaults.DefaultAddress) + dataStore := filepath.Join(dataRoot, h) + + namespace := string(helpers.Read(nerdtest.Namespace)) + + containersPath := filepath.Join(dataStore, "containers", namespace) + containersDirs, err := os.ReadDir(containersPath) + assert.NilError(t, err) + assert.Equal(t, len(containersDirs), 1) + + etchostsPath := filepath.Join(dataStore, "etchosts", namespace) + etchostsDirs, err := os.ReadDir(etchostsPath) + assert.NilError(t, err) + assert.Equal(t, len(etchostsDirs), 1) + + data.Set(containersPathKey, containersPath) + data.Set(etchostsPathKey, etchostsPath) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "--data-root", data.TempDir(), "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--data-root", data.TempDir(), "--name", data.Identifier(), "-d", testutil.AlpineImage, "sleep", nerdtest.Infinity) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("is already used by ID")}, + Output: func(stdout string, info string, t *testing.T) { + containersDirs, err := os.ReadDir(data.Get(containersPathKey)) + assert.NilError(t, err) + assert.Equal(t, len(containersDirs), 1) + + etchostsDirs, err := os.ReadDir(data.Get(etchostsPathKey)) + assert.NilError(t, err) + assert.Equal(t, len(etchostsDirs), 1) + }, + } + }, + }, + { + Description: "Issue #2993 - nerdctl no longer leaks containers and etchosts directories and files when containers are removed.", + Setup: func(data test.Data, helpers test.Helpers) { + dataRoot := data.TempDir() + + helpers.Ensure("run", "--data-root", dataRoot, "--name", data.Identifier(), "-d", testutil.AlpineImage, "sleep", nerdtest.Infinity) + + h := getAddrHash(defaults.DefaultAddress) + dataStore := filepath.Join(dataRoot, h) + + namespace := string(helpers.Read(nerdtest.Namespace)) + + containersPath := filepath.Join(dataStore, "containers", namespace) + containersDirs, err := os.ReadDir(containersPath) + assert.NilError(t, err) + assert.Equal(t, len(containersDirs), 1) + + etchostsPath := filepath.Join(dataStore, "etchosts", namespace) + etchostsDirs, err := os.ReadDir(etchostsPath) + assert.NilError(t, err) + assert.Equal(t, len(etchostsDirs), 1) + + data.Set(containersPathKey, containersPath) + data.Set(etchostsPathKey, etchostsPath) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--data-root", data.TempDir(), "rm", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--data-root", data.TempDir(), "rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + containersDirs, err := os.ReadDir(data.Get(containersPathKey)) + assert.NilError(t, err) + assert.Equal(t, len(containersDirs), 0) + + etchostsDirs, err := os.ReadDir(data.Get(etchostsPathKey)) + assert.NilError(t, err) + assert.Equal(t, len(etchostsDirs), 0) + }, + } + }, + }, + } + + testCase.Run(t) +} + +func TestCreateFromOCIArchive(t *testing.T) { + testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + + // Docker does not support creating containers from OCI archive. + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + imageName := testutil.Identifier(t) + containerName := testutil.Identifier(t) + + teardown := func() { + base.Cmd("rm", "-f", containerName).Run() + base.Cmd("rmi", "-f", imageName).Run() + } + defer teardown() + teardown() + + const sentinel = "test-nerdctl-create-from-oci-archive" + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "%s"]`, testutil.CommonImage, sentinel) + + buildCtx := helpers.CreateBuildContext(t, dockerfile) + tag := fmt.Sprintf("%s:latest", imageName) + 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("create", "--rm", "--name", containerName, fmt.Sprintf("oci-archive://%s", tarPath)).AssertOK() + base.Cmd("start", "--attach", containerName).AssertOutContains("test-nerdctl-create-from-oci-archive") +} diff --git a/cmd/nerdctl/container/container_create_test.go b/cmd/nerdctl/container/container_create_test.go new file mode 100644 index 00000000000..6885a1e64aa --- /dev/null +++ b/cmd/nerdctl/container/container_create_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 container + +import ( + "encoding/json" + "testing" + "time" + + "gotest.tools/v3/assert" + + "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/test" +) + +func TestCreate(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("create", "--name", data.Identifier("container"), testutil.CommonImage, "echo", "foo") + data.Set("cID", data.Identifier("container")) + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container")) + } + + testCase.Require = nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3717") + + testCase.SubTests = []*test.Case{ + { + Description: "ps -a", + NoParallel: true, + Command: test.Command("ps", "-a"), + // FIXME: this might get a false positive if other tests have created a container + Expected: test.Expects(0, nil, test.Contains("Created")), + }, + { + Description: "start", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("start", data.Get("cID")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "logs", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", data.Get("cID")) + }, + Expected: test.Expects(0, nil, test.Contains("foo")), + }, + } + + testCase.Run(t) +} + +func TestCreateHyperVContainer(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.HyperV + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("create", "--isolation", "hyperv", "--name", data.Identifier("container"), testutil.CommonImage, "echo", "foo") + data.Set("cID", data.Identifier("container")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "ps -a", + NoParallel: true, + Command: test.Command("ps", "-a"), + // FIXME: this might get a false positive if other tests have created a container + Expected: test.Expects(0, nil, test.Contains("Created")), + }, + { + Description: "start", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("start", data.Get("cID")) + ran := false + for i := 0; i < 10 && !ran; i++ { + helpers.Command("container", "inspect", data.Get("cID")). + Run(&test.Expected{ + ExitCode: test.ExitCodeNoCheck, + Output: func(stdout string, info string, t *testing.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) + ran = dc[0].State.Status == "exited" + }, + }) + time.Sleep(time.Second) + } + assert.Assert(t, ran, "container did not ran after 10 seconds") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("logs", data.Get("cID")) + }, + Expected: test.Expects(0, nil, test.Contains("foo")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container/container_diff.go b/cmd/nerdctl/container/container_diff.go new file mode 100644 index 00000000000..9b3e5be47eb --- /dev/null +++ b/cmd/nerdctl/container/container_diff.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 container + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/opencontainers/image-spec/identity" + "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/leases" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/continuity/fs" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "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/idgen" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/labels" +) + +func NewDiffCommand() *cobra.Command { + var diffCommand = &cobra.Command{ + Use: "diff [CONTAINER]", + Short: "Inspect changes to files or directories on a container's filesystem", + Args: cobra.MinimumNArgs(1), + RunE: diffAction, + ValidArgsFunction: diffShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + return diffCommand +} + +func processContainerDiffOptions(cmd *cobra.Command) (types.ContainerDiffOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ContainerDiffOptions{}, err + } + + return types.ContainerDiffOptions{ + Stdout: cmd.OutOrStdout(), + GOptions: globalOptions, + }, nil +} + +func diffAction(cmd *cobra.Command, args []string) error { + options, err := processContainerDiffOptions(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() + + 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) + } + changes, err := getChanges(ctx, client, found.Container) + if err != nil { + return err + } + + for _, change := range changes { + switch change.Kind { + case fs.ChangeKindAdd: + fmt.Fprintln(options.Stdout, "A", change.Path) + case fs.ChangeKindModify: + fmt.Fprintln(options.Stdout, "C", change.Path) + case fs.ChangeKindDelete: + fmt.Fprintln(options.Stdout, "D", change.Path) + default: + } + } + + return nil + }, + } + + container := args[0] + + n, err := walker.Walk(ctx, container) + if err != nil { + return err + } else if n == 0 { + return fmt.Errorf("no such container %s", container) + } + return nil +} + +func getChanges(ctx context.Context, client *containerd.Client, container containerd.Container) ([]fs.Change, error) { + id := container.ID() + info, err := container.Info(ctx) + if err != nil { + return nil, err + } + + var ( + snName = info.Snapshotter + sn = client.SnapshotService(snName) + ) + + mounts, err := sn.Mounts(ctx, id) + if err != nil { + return nil, err + } + + // NOTE: Moby uses provided rootfs to run container. It doesn't support + // to commit container created by moby. + baseImgWithoutPlatform, err := client.ImageService().Get(ctx, info.Image) + if err != nil { + return nil, fmt.Errorf("container %q lacks image (wasn't created by nerdctl?): %w", id, err) + } + platformLabel := info.Labels[labels.Platform] + if platformLabel == "" { + platformLabel = platforms.DefaultString() + log.G(ctx).Warnf("Image lacks label %q, assuming the platform to be %q", labels.Platform, platformLabel) + } + ocispecPlatform, err := platforms.Parse(platformLabel) + if err != nil { + return nil, err + } + log.G(ctx).Debugf("ocispecPlatform=%q", platforms.Format(ocispecPlatform)) + platformMC := platforms.Only(ocispecPlatform) + baseImg := containerd.NewImageWithPlatform(client, baseImgWithoutPlatform, platformMC) + + baseImgConfig, _, err := imgutil.ReadImageConfig(ctx, baseImg) + if err != nil { + return nil, err + } + + // Don't gc me and clean the dirty data after 1 hour! + ctx, done, err := client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour)) + if err != nil { + return nil, fmt.Errorf("failed to create lease for diff: %w", err) + } + defer done(ctx) + + rootfsID := identity.ChainID(baseImgConfig.RootFS.DiffIDs).String() + + randomID := idgen.GenerateID() + parent, err := sn.View(ctx, randomID, rootfsID) + if err != nil { + return nil, err + } + defer sn.Remove(ctx, randomID) + + var changes []fs.Change + err = mount.WithReadonlyTempMount(ctx, parent, func(lower string) error { + return mount.WithReadonlyTempMount(ctx, mounts, func(upper string) error { + return fs.Changes(ctx, lower, upper, func(ck fs.ChangeKind, s string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + changes = appendChanges(changes, fs.Change{ + Kind: ck, + Path: s, + }) + return nil + }) + }) + }) + if err != nil { + return nil, err + } + + return changes, err +} + +func appendChanges(changes []fs.Change, fsChange fs.Change) []fs.Change { + newDir, _ := filepath.Split(fsChange.Path) + newDirPath := filepath.SplitList(newDir) + + if len(changes) == 0 { + for i := 1; i < len(newDirPath); i++ { + changes = append(changes, fs.Change{ + Kind: fs.ChangeKindModify, + Path: filepath.Join(newDirPath[:i+1]...), + }) + } + return append(changes, fsChange) + } + last := changes[len(changes)-1] + lastDir, _ := filepath.Split(last.Path) + lastDirPath := filepath.SplitList(lastDir) + for i := range newDirPath { + if len(lastDirPath) > i && lastDirPath[i] == newDirPath[i] { + continue + } + changes = append(changes, fs.Change{ + Kind: fs.ChangeKindModify, + Path: filepath.Join(newDirPath[:i+1]...), + }) + } + return append(changes, fsChange) +} + +func diffShellComplete(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_diff_test.go b/cmd/nerdctl/container/container_diff_test.go new file mode 100644 index 00000000000..b47631dd600 --- /dev/null +++ b/cmd/nerdctl/container/container_diff_test.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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestDiff(t *testing.T) { + testCase := nerdtest.Setup() + + // It is unclear why this is failing with docker when run in parallel + // Obviously some other container test is interfering + if nerdtest.IsDocker() { + testCase.NoParallel = true + } + + testCase.Require = test.Not(test.Windows) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, + "sh", "-euxc", "touch /a; touch /bin/b; rm /bin/base64") + } + + 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("diff", data.Identifier()) + } + + testCase.Expected = test.Expects(0, nil, test.All( + test.Contains("A /a"), + test.Contains("C /bin"), + test.Contains("A /bin/b"), + test.Contains("D /bin/base64"), + )) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_exec.go b/cmd/nerdctl/container/container_exec.go similarity index 88% rename from cmd/nerdctl/container_exec.go rename to cmd/nerdctl/container/container_exec.go index 6b3447cd94b..fc6f0c0c6ac 100644 --- a/cmd/nerdctl/container_exec.go +++ b/cmd/nerdctl/container/container_exec.go @@ -14,19 +14,23 @@ limitations under the License. */ -package main +package container import ( "errors" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newExecCommand() *cobra.Command { +func NewExecCommand() *cobra.Command { var execCommand = &cobra.Command{ Use: "exec [flags] CONTAINER COMMAND [ARG...]", Args: cobra.MinimumNArgs(2), @@ -52,7 +56,8 @@ func newExecCommand() *cobra.Command { } func processExecCommandOptions(cmd *cobra.Command) (types.ContainerExecOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + // We do not check if we have a terminal here, as container.Exec calling console.Current will ensure that + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerExecOptions{}, err } @@ -145,7 +150,7 @@ func execShellComplete(cmd *cobra.Command, args []string, toComplete string) ([] statusFilterFn := func(st containerd.ProcessStatus) bool { return st == containerd.Running } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/nerdctl/container_exec_linux_test.go b/cmd/nerdctl/container/container_exec_linux_test.go similarity index 91% rename from cmd/nerdctl/container_exec_linux_test.go rename to cmd/nerdctl/container/container_exec_linux_test.go index 37ffcb69d06..19113781ebc 100644 --- a/cmd/nerdctl/container_exec_linux_test.go +++ b/cmd/nerdctl/container/container_exec_linux_test.go @@ -14,12 +14,13 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestExecWithUser(t *testing.T) { @@ -28,7 +29,7 @@ func TestExecWithUser(t *testing.T) { testContainer := testutil.Identifier(t) defer base.Cmd("rm", "-f", testContainer).Run() - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() base.EnsureContainerStarted(testContainer) testCases := map[string]string{ @@ -59,7 +60,7 @@ func TestExecTTY(t *testing.T) { testContainer := testutil.Identifier(t) defer base.Cmd("rm", "-f", testContainer).Run() - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() const sttyPartialOutput = "speed 38400 baud" // unbuffer(1) emulates tty, which is required by `nerdctl run -t`. diff --git a/cmd/nerdctl/container_exec_test.go b/cmd/nerdctl/container/container_exec_test.go similarity index 96% rename from cmd/nerdctl/container_exec_test.go rename to cmd/nerdctl/container/container_exec_test.go index b96cc74756a..de58789ab77 100644 --- a/cmd/nerdctl/container_exec_test.go +++ b/cmd/nerdctl/container/container_exec_test.go @@ -14,16 +14,15 @@ limitations under the License. */ -package main +package container import ( "errors" - "os" "runtime" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestExec(t *testing.T) { @@ -80,7 +79,7 @@ func TestExecEnv(t *testing.T) { base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() base.EnsureContainerStarted(testContainer) - base.Env = append(os.Environ(), "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") + 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", diff --git a/cmd/nerdctl/container_inspect.go b/cmd/nerdctl/container/container_inspect.go similarity index 81% rename from cmd/nerdctl/container_inspect.go rename to cmd/nerdctl/container/container_inspect.go index 0124a93dcda..6dae2cbf613 100644 --- a/cmd/nerdctl/container_inspect.go +++ b/cmd/nerdctl/container/container_inspect.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package container import ( "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "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 newContainerInspectCommand() *cobra.Command { @@ -38,6 +40,8 @@ func newContainerInspectCommand() *cobra.Command { SilenceErrors: true, } containerInspectCommand.Flags().String("mode", "dockercompat", `Inspect mode, "dockercompat" for Docker-compatible output, "native" for containerd-native output`) + containerInspectCommand.Flags().BoolP("size", "s", false, "Display total file sizes") + containerInspectCommand.RegisterFlagCompletionFunc("mode", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"dockercompat", "native"}, cobra.ShellCompDirectiveNoFileComp }) @@ -53,8 +57,8 @@ var validModeType = map[string]bool{ "dockercompat": true, } -func processContainerInspectOptions(cmd *cobra.Command) (opt types.ContainerInspectOptions, err error) { - globalOptions, err := processRootCmdFlags(cmd) +func ProcessContainerInspectOptions(cmd *cobra.Command) (opt types.ContainerInspectOptions, err error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return } @@ -71,16 +75,22 @@ func processContainerInspectOptions(cmd *cobra.Command) (opt types.ContainerInsp return } + size, err := cmd.Flags().GetBool("size") + if err != nil { + return + } + return types.ContainerInspectOptions{ GOptions: globalOptions, Format: format, Mode: mode, + Size: size, Stdout: cmd.OutOrStdout(), }, nil } func containerInspectAction(cmd *cobra.Command, args []string) error { - opt, err := processContainerInspectOptions(cmd) + opt, err := ProcessContainerInspectOptions(cmd) if err != nil { return err } @@ -95,5 +105,5 @@ func containerInspectAction(cmd *cobra.Command, args []string) error { func containerInspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show container names - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } diff --git a/cmd/nerdctl/container_inspect_linux_test.go b/cmd/nerdctl/container/container_inspect_linux_test.go similarity index 85% rename from cmd/nerdctl/container_inspect_linux_test.go rename to cmd/nerdctl/container/container_inspect_linux_test.go index 6eb0ba9b2f1..2fadc2b5048 100644 --- a/cmd/nerdctl/container_inspect_linux_test.go +++ b/cmd/nerdctl/container/container_inspect_linux_test.go @@ -14,17 +14,19 @@ limitations under the License. */ -package main +package container import ( "fmt" "strings" "testing" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/testutil" "github.com/docker/go-connections/nat" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestContainerInspectContainsPortConfig(t *testing.T) { @@ -156,6 +158,25 @@ func TestContainerInspectContainsLabel(t *testing.T) { assert.Equal(base.T, "bar", lbs["bar"]) } +func TestContainerInspectContainsInternalLabel(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + testContainer := testutil.Identifier(t) + + base := testutil.NewBase(t) + defer base.Cmd("rm", "-f", testContainer).Run() + + base.Cmd("run", "-d", "--name", testContainer, "--mount", "type=bind,src=/tmp,dst=/app,readonly=false,bind-propagation=rprivate", testutil.NginxAlpineImage).AssertOK() + base.EnsureContainerStarted(testContainer) + inspect := base.InspectContainer(testContainer) + lbs := inspect.Config.Labels + + // TODO: add more internal labels testcases + labelMount := lbs[labels.Mounts] + expectedLabelMount := "[{\"Type\":\"bind\",\"Source\":\"/tmp\",\"Destination\":\"/app\",\"Mode\":\"rprivate,rbind\",\"RW\":true,\"Propagation\":\"rprivate\"}]" + assert.Equal(base.T, expectedLabelMount, labelMount) +} + func TestContainerInspectState(t *testing.T) { t.Parallel() testContainer := testutil.Identifier(t) diff --git a/cmd/nerdctl/container_inspect_windows_test.go b/cmd/nerdctl/container/container_inspect_windows_test.go similarity index 96% rename from cmd/nerdctl/container_inspect_windows_test.go rename to cmd/nerdctl/container/container_inspect_windows_test.go index 5ba9adbb08a..8feb7fcd52d 100644 --- a/cmd/nerdctl/container_inspect_windows_test.go +++ b/cmd/nerdctl/container/container_inspect_windows_test.go @@ -14,13 +14,14 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestInspectProcessContainerContainsLabel(t *testing.T) { diff --git a/cmd/nerdctl/container_kill.go b/cmd/nerdctl/container/container_kill.go similarity index 79% rename from cmd/nerdctl/container_kill.go rename to cmd/nerdctl/container/container_kill.go index fdf8e488c72..154cec0e23f 100644 --- a/cmd/nerdctl/container_kill.go +++ b/cmd/nerdctl/container/container_kill.go @@ -14,17 +14,21 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newKillCommand() *cobra.Command { +func NewKillCommand() *cobra.Command { var killCommand = &cobra.Command{ Use: "kill [flags] CONTAINER [CONTAINER, ...]", Short: "Kill one or more running containers", @@ -39,7 +43,7 @@ func newKillCommand() *cobra.Command { } func killAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -68,5 +72,5 @@ func killShellComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobr statusFilterFn := func(st containerd.ProcessStatus) bool { return st != containerd.Stopped && st != containerd.Created && st != containerd.Unknown } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container/container_kill_linux_test.go b/cmd/nerdctl/container/container_kill_linux_test.go new file mode 100644 index 00000000000..0e7d3fc3f62 --- /dev/null +++ b/cmd/nerdctl/container/container_kill_linux_test.go @@ -0,0 +1,79 @@ +/* + 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" + "strings" + "testing" + + "github.com/coreos/go-iptables/iptables" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + iptablesutil "github.com/containerd/nerdctl/v2/pkg/testutil/iptables" +) + +// TestKillCleanupForwards runs a container that exposes a port and then kill it. +// The test checks that the kill command effectively clean up +// the iptables forwards creted from the run. +func TestKillCleanupForwards(t *testing.T) { + const ( + hostPort = 9999 + testContainerName = "ngx" + ) + base := testutil.NewBase(t) + defer func() { + base.Cmd("rm", "-f", testContainerName).Run() + }() + + // skip if rootless + if rootlessutil.IsRootless() { + t.Skip("pkg/testutil/iptables does not support rootless") + } + + ipt, err := iptables.New() + assert.NilError(t, err) + + containerID := base.Cmd("run", "-d", + "--restart=no", + "--name", testContainerName, + "-p", fmt.Sprintf("127.0.0.1:%d:80", hostPort), + testutil.NginxAlpineImage).Run().Stdout() + containerID = strings.TrimSuffix(containerID, "\n") + + containerIP := base.Cmd("inspect", + "-f", + "'{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", + testContainerName).Run().Stdout() + containerIP = strings.ReplaceAll(containerIP, "'", "") + containerIP = strings.TrimSuffix(containerIP, "\n") + + // define iptables chain name depending on the target (docker/nerdctl) + var chain string + if testutil.GetTarget() == testutil.Docker { + chain = "DOCKER" + } else { + redirectChain := "CNI-HOSTPORT-DNAT" + chain = iptablesutil.GetRedirectedChain(t, ipt, redirectChain, testutil.Namespace, containerID) + } + assert.Equal(t, iptablesutil.ForwardExists(t, ipt, chain, containerIP, hostPort), true) + + base.Cmd("kill", testContainerName).AssertOK() + assert.Equal(t, iptablesutil.ForwardExists(t, ipt, chain, containerIP, hostPort), false) +} diff --git a/cmd/nerdctl/container/container_list.go b/cmd/nerdctl/container/container_list.go new file mode 100644 index 00000000000..c9f0d3bd2bb --- /dev/null +++ b/cmd/nerdctl/container/container_list.go @@ -0,0 +1,235 @@ +/* + 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 ( + "bytes" + "errors" + "fmt" + "io" + "text/tabwriter" + "text/template" + + "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/container" + "github.com/containerd/nerdctl/v2/pkg/formatter" +) + +func NewPsCommand() *cobra.Command { + var psCommand = &cobra.Command{ + Use: "ps", + Args: cobra.NoArgs, + Short: "List containers", + RunE: psAction, + SilenceUsage: true, + SilenceErrors: true, + } + psCommand.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") + psCommand.Flags().IntP("last", "n", -1, "Show n last created containers (includes all states)") + psCommand.Flags().BoolP("latest", "l", false, "Show the latest created container (includes all states)") + psCommand.Flags().Bool("no-trunc", false, "Don't truncate output") + psCommand.Flags().BoolP("quiet", "q", false, "Only display container IDs") + psCommand.Flags().BoolP("size", "s", false, "Display total file sizes") + + // Alias "-f" is reserved for "--filter" + psCommand.Flags().String("format", "", "Format the output using the given Go template, e.g, '{{json .}}', 'wide'") + psCommand.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "table", "wide"}, cobra.ShellCompDirectiveNoFileComp + }) + psCommand.Flags().StringSliceP("filter", "f", nil, "Filter matches containers based on given conditions. When specifying the condition 'status', it filters all containers") + return psCommand +} + +func processOptions(cmd *cobra.Command) (types.ContainerListOptions, FormattingAndPrintingOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + all, err := cmd.Flags().GetBool("all") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + latest, err := cmd.Flags().GetBool("latest") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + + lastN, err := cmd.Flags().GetInt("last") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + if lastN == -1 && latest { + lastN = 1 + } + + filters, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + + noTrunc, err := cmd.Flags().GetBool("no-trunc") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + trunc := !noTrunc + + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + + size := false + if !quiet { + size, err = cmd.Flags().GetBool("size") + if err != nil { + return types.ContainerListOptions{}, FormattingAndPrintingOptions{}, err + } + } + + return types.ContainerListOptions{ + GOptions: globalOptions, + All: all, + LastN: lastN, + Truncate: trunc, + Size: size || (format == "wide" && !quiet), + Filters: filters, + }, FormattingAndPrintingOptions{ + Stdout: cmd.OutOrStdout(), + Quiet: quiet, + Format: format, + Size: size, + }, nil +} + +func psAction(cmd *cobra.Command, args []string) error { + clOpts, fpOpts, err := processOptions(cmd) + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), clOpts.GOptions.Namespace, clOpts.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + containers, err := container.List(ctx, client, clOpts) + if err != nil { + return err + } + + return formatAndPrintContainerInfo(containers, fpOpts) +} + +// FormattingAndPrintingOptions specifies options for formatting and printing of `nerdctl (container) list`. +type FormattingAndPrintingOptions struct { + Stdout io.Writer + // Only display container IDs. + Quiet bool + // Format the output using the given Go template (e.g., '{{json .}}', 'table', 'wide'). + Format string + // Display total file sizes. + Size bool +} + +func formatAndPrintContainerInfo(containers []container.ListItem, options FormattingAndPrintingOptions) error { + w := options.Stdout + var ( + wide bool + tmpl *template.Template + ) + switch options.Format { + case "", "table": + w = tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) + if !options.Quiet { + printHeader := "CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES" + if options.Size { + printHeader += "\tSIZE" + } + fmt.Fprintln(w, printHeader) + } + case "raw": + return errors.New("unsupported format: \"raw\"") + case "wide": + w = tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) + if !options.Quiet { + fmt.Fprintln(w, "CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES\tRUNTIME\tPLATFORM\tSIZE") + wide = true + } + 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 _, c := range containers { + if tmpl != nil { + var b bytes.Buffer + if err := tmpl.Execute(&b, &c); err != nil { + return err + } + if _, err := fmt.Fprintln(w, b.String()); err != nil { + return err + } + } else if options.Quiet { + if _, err := fmt.Fprintln(w, c.ID); err != nil { + return err + } + } else { + format := "%s\t%s\t%s\t%s\t%s\t%s\t%s" + args := []interface{}{ + c.ID, + c.Image, + c.Command, + formatter.TimeSinceInHuman(c.CreatedAt), + c.Status, + c.Ports, + c.Names, + } + if wide { + format += "\t%s\t%s\t%s\n" + args = append(args, c.Runtime, c.Platform, c.Size) + } else if options.Size { + format += "\t%s\n" + args = append(args, c.Size) + } else { + format += "\n" + } + if _, err := fmt.Fprintf(w, format, args...); err != nil { + return err + } + } + + } + if f, ok := w.(formatter.Flusher); ok { + return f.Flush() + } + return nil +} diff --git a/cmd/nerdctl/container_list_linux_test.go b/cmd/nerdctl/container/container_list_linux_test.go similarity index 86% rename from cmd/nerdctl/container_list_linux_test.go rename to cmd/nerdctl/container/container_list_linux_test.go index d7b04a3f005..79df967332a 100644 --- a/cmd/nerdctl/container_list_linux_test.go +++ b/cmd/nerdctl/container/container_list_linux_test.go @@ -14,20 +14,22 @@ limitations under the License. */ -package main +package container import ( "errors" "fmt" "os" + "slices" "strings" "testing" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/containerd/nerdctl/pkg/tabutil" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) type psTestContainer struct { @@ -37,7 +39,8 @@ type psTestContainer struct { network string } -func preparePsTestContainer(t *testing.T, identity string, restart bool) (*testutil.Base, psTestContainer) { +// When keepAlive is false, the container will exit immediately with status 1. +func preparePsTestContainer(t *testing.T, identity string, keepAlive bool) (*testutil.Base, psTestContainer) { base := testutil.NewBase(t) base.Cmd("pull", testutil.CommonImage).AssertOK() @@ -82,21 +85,23 @@ func preparePsTestContainer(t *testing.T, identity string, restart bool) (*testu "-v", mnt1, "-v", mnt2, "--net", testContainerName, - testutil.CommonImage, - "top", } - if !restart { - args = append(args, "--restart=no") + if keepAlive { + args = append(args, testutil.CommonImage, "top") + } else { + args = append(args, "--restart=no", testutil.CommonImage, "false") } base.Cmd(args...).AssertOK() - if restart { + if keepAlive { base.EnsureContainerStarted(testContainerName) + } else { + base.EnsureContainerExited(testContainerName, 1) } // dd if=/dev/zero of=test_file bs=1M count=25 // let the container occupy 25MiB space. - if restart { + if keepAlive { base.Cmd("exec", testContainerName, "dd", "if=/dev/zero", "of=/test_file", "bs=1M", "count=25").AssertOK() } volumes := []string{} @@ -223,6 +228,24 @@ func TestContainerListWithLabels(t *testing.T) { }) } +func TestContainerListWithNames(t *testing.T) { + base, testContainer := preparePsTestContainer(t, "listWithNames", true) + + // hope there are no tests running parallel + base.Cmd("ps", "-n", "1", "--format", "{{.Names}}").AssertOutWithFunc(func(stdout string) error { + + // An example of nerdctl ps --format "{{.Names}}" + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 1 { + return fmt.Errorf("expected 1 line, got %d", len(lines)) + } + + assert.Equal(t, lines[0], testContainer.name) + + return nil + }) +} + func TestContainerListWithFilter(t *testing.T) { base, testContainerA := preparePsTestContainer(t, "listWithFilterA", true) _, testContainerB := preparePsTestContainer(t, "listWithFilterB", true) @@ -553,4 +576,54 @@ func TestContainerListWithFilter(t *testing.T) { } return nil }) + + // filter container state without option "-a". + base.Cmd("ps", "--filter", "status=exited").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) + } + containerNames := map[string]struct{}{ + testContainerC.name: {}, + } + for idx, line := range lines { + if idx == 0 { + continue + } + containerName, _ := tab.ReadRow(line, "NAMES") + if _, ok := containerNames[containerName]; !ok { + return fmt.Errorf("unexpected container %s found", containerName) + } + } + return nil + }) +} + +func TestContainerListCheckCreatedTime(t *testing.T) { + base, _ := preparePsTestContainer(t, "checkCreatedTimeA", true) + preparePsTestContainer(t, "checkCreatedTimeB", true) + preparePsTestContainer(t, "checkCreatedTimeC", false) + preparePsTestContainer(t, "checkCreatedTimeD", false) + + var createdTimes []string + + base.Cmd("ps", "--format", "'{{json .CreatedAt}}'", "-a").AssertOutWithFunc(func(stdout string) error { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 4 { + return fmt.Errorf("expected at least 4 lines, got %d", len(lines)) + } + createdTimes = append(createdTimes, lines...) + return nil + }) + + slices.Reverse(createdTimes) + if !slices.IsSorted(createdTimes) { + t.Errorf("expected containers in decending order") + } } diff --git a/cmd/nerdctl/container/container_list_test.go b/cmd/nerdctl/container/container_list_test.go new file mode 100644 index 00000000000..751cfabc64c --- /dev/null +++ b/cmd/nerdctl/container/container_list_test.go @@ -0,0 +1,62 @@ +/* + 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" + "testing" + + "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") +} + +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)) +} diff --git a/cmd/nerdctl/container_list_windows_test.go b/cmd/nerdctl/container/container_list_windows_test.go similarity index 97% rename from cmd/nerdctl/container_list_windows_test.go rename to cmd/nerdctl/container/container_list_windows_test.go index e655a6cd1b3..39eec453684 100644 --- a/cmd/nerdctl/container_list_windows_test.go +++ b/cmd/nerdctl/container/container_list_windows_test.go @@ -14,24 +14,24 @@ limitations under the License. */ -package main +package container import ( "fmt" "strings" "testing" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/containerd/nerdctl/pkg/tabutil" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) type psTestContainer struct { name string labels map[string]string - volumes []string network string } diff --git a/cmd/nerdctl/container_logs.go b/cmd/nerdctl/container/container_logs.go similarity index 89% rename from cmd/nerdctl/container_logs.go rename to cmd/nerdctl/container/container_logs.go index 7ec46f80ccc..910cbeffa03 100644 --- a/cmd/nerdctl/container_logs.go +++ b/cmd/nerdctl/container/container_logs.go @@ -14,19 +14,22 @@ limitations under the License. */ -package main +package container import ( "fmt" "strconv" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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 newLogsCommand() *cobra.Command { +func NewLogsCommand() *cobra.Command { const shortUsage = "Fetch the logs of a container. Expected to be used with 'nerdctl run -d'." const longUsage = `Fetch the logs of a container. @@ -37,7 +40,7 @@ The following containers are supported: ` var logsCommand = &cobra.Command{ Use: "logs [flags] CONTAINER", - Args: IsExactArgs(1), + Args: helpers.IsExactArgs(1), Short: shortUsage, Long: longUsage, RunE: logsAction, @@ -54,7 +57,7 @@ The following containers are supported: } func processContainerLogsOptions(cmd *cobra.Command) (types.ContainerLogsOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerLogsOptions{}, err } @@ -114,7 +117,7 @@ func logsAction(cmd *cobra.Command, args []string) error { func logsShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show container names (TODO: only show containers with logs) - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } // Attempts to parse the argument given to `-n/--tail` as a uint. diff --git a/cmd/nerdctl/container_logs_test.go b/cmd/nerdctl/container/container_logs_test.go similarity index 58% rename from cmd/nerdctl/container_logs_test.go rename to cmd/nerdctl/container/container_logs_test.go index 3f78ba8e2ce..71debda52e9 100644 --- a/cmd/nerdctl/container_logs_test.go +++ b/cmd/nerdctl/container/container_logs_test.go @@ -14,15 +14,18 @@ limitations under the License. */ -package main +package container import ( "fmt" + "runtime" "strings" "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestLogs(t *testing.T) { @@ -38,9 +41,9 @@ bar` //test since / until flag time.Sleep(3 * time.Second) - base.Cmd("logs", "--since", "1s", containerName).AssertNoOut(expected) + base.Cmd("logs", "--since", "1s", containerName).AssertOutNotContains(expected) base.Cmd("logs", "--since", "10s", containerName).AssertOutContains(expected) - base.Cmd("logs", "--until", "10s", containerName).AssertNoOut(expected) + base.Cmd("logs", "--until", "10s", containerName).AssertOutNotContains(expected) base.Cmd("logs", "--until", "1s", containerName).AssertOutContains(expected) // Ensure follow flag works as expected: @@ -79,6 +82,7 @@ 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 { @@ -92,6 +96,9 @@ func TestLogsWithInheritedFlags(t *testing.T) { base.Cmd("run", "-d", "--name", containerName, testutil.CommonImage, "sh", "-euxc", "echo foo; echo bar").AssertOK() + // 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 == "") { @@ -140,6 +147,103 @@ func TestLogsWithFailingContainer(t *testing.T) { // 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).AssertNoOut("baz") + base.Cmd("logs", "-f", containerName).AssertOutNotContains("baz") base.Cmd("rm", "-f", containerName).AssertOK() } + +func TestLogsWithForegroundContainers(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("dual logging is not supported on Windows") + } + base := testutil.NewBase(t) + tid := testutil.Identifier(t) + + // unbuffer(1) emulates tty, which is required by `nerdctl run -t`. + // unbuffer(1) can be installed with `apt-get install expect`. + unbuffer := []string{"unbuffer"} + + testCases := []struct { + name string + flags []string + tty bool + }{ + { + name: "foreground", + flags: nil, + tty: false, + }, + { + name: "interactive", + flags: []string{"-i"}, + tty: false, + }, + { + name: "PTY", + flags: []string{"-t"}, + tty: true, + }, + { + name: "interactivePTY", + flags: []string{"-i", "-t"}, + tty: true, + }, + } + + for _, tc := range testCases { + tc := tc + func(t *testing.T) { + containerName := tid + "-" + tc.name + var cmdArgs []string + defer base.Cmd("rm", "-f", containerName).Run() + cmdArgs = append(cmdArgs, "run", "--name", containerName) + cmdArgs = append(cmdArgs, tc.flags...) + cmdArgs = append(cmdArgs, testutil.CommonImage, "sh", "-euxc", "echo foo; echo bar") + + if tc.tty { + base.CmdWithHelper(unbuffer, cmdArgs...).AssertOK() + } else { + base.Cmd(cmdArgs...).AssertOK() + } + + base.Cmd("logs", containerName).AssertOutContains("foo") + base.Cmd("logs", containerName).AssertOutContains("bar") + base.Cmd("logs", containerName).AssertOutNotContains("baz") + }(t) + } +} + +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) + + 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) + } + } + assert.Equal(t, true, len(tailLogs) > linesPerFile, logRun.Stderr()) +} diff --git a/cmd/nerdctl/container_pause.go b/cmd/nerdctl/container/container_pause.go similarity index 79% rename from cmd/nerdctl/container_pause.go rename to cmd/nerdctl/container/container_pause.go index 8e4f0c85f2f..338652f8d96 100644 --- a/cmd/nerdctl/container_pause.go +++ b/cmd/nerdctl/container/container_pause.go @@ -14,17 +14,21 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newPauseCommand() *cobra.Command { +func NewPauseCommand() *cobra.Command { var pauseCommand = &cobra.Command{ Use: "pause [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -38,7 +42,7 @@ func newPauseCommand() *cobra.Command { } func processContainerPauseOptions(cmd *cobra.Command) (types.ContainerPauseOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerPauseOptions{}, err } @@ -68,5 +72,5 @@ func pauseShellComplete(cmd *cobra.Command, args []string, toComplete string) ([ statusFilterFn := func(st containerd.ProcessStatus) bool { return st == containerd.Running } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container_port.go b/cmd/nerdctl/container/container_port.go similarity index 84% rename from cmd/nerdctl/container_port.go rename to cmd/nerdctl/container/container_port.go index e2fb467f000..1b618be020b 100644 --- a/cmd/nerdctl/container_port.go +++ b/cmd/nerdctl/container/container_port.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "context" @@ -22,14 +22,16 @@ import ( "strconv" "strings" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "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/clientutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) -func newPortCommand() *cobra.Command { +func NewPortCommand() *cobra.Command { var portCommand = &cobra.Command{ Use: "port [flags] CONTAINER [PRIVATE_PORT[/PROTO]]", Args: cobra.RangeArgs(1, 2), @@ -43,7 +45,7 @@ func newPortCommand() *cobra.Command { } func portAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -99,5 +101,5 @@ func portAction(cmd *cobra.Command, args []string) error { } func portShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } diff --git a/cmd/nerdctl/container_prune.go b/cmd/nerdctl/container/container_prune.go similarity index 79% rename from cmd/nerdctl/container_prune.go rename to cmd/nerdctl/container/container_prune.go index dc02b5a316b..79ce0d8bb56 100644 --- a/cmd/nerdctl/container_prune.go +++ b/cmd/nerdctl/container/container_prune.go @@ -14,16 +14,15 @@ limitations under the License. */ -package main +package container import ( - "fmt" - "strings" - - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/container" ) func newContainerPruneCommand() *cobra.Command { @@ -40,7 +39,7 @@ func newContainerPruneCommand() *cobra.Command { } func processContainerPruneOptions(cmd *cobra.Command) (types.ContainerPruneOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerPruneOptions{}, err } @@ -58,15 +57,7 @@ func grantPrunePermission(cmd *cobra.Command) (bool, error) { } if !force { - var confirm string - msg := "This will remove all stopped containers." - msg += "\nAre you sure you want to continue? [y/N] " - fmt.Fprintf(cmd.OutOrStdout(), "WARNING! %s", msg) - fmt.Fscanf(cmd.InOrStdin(), "%s", &confirm) - - if strings.ToLower(confirm) != "y" { - return false, nil - } + return helpers.Confirm(cmd, "WARNING! This will remove all stopped containers.") } return true, nil } diff --git a/cmd/nerdctl/container/container_prune_linux_test.go b/cmd/nerdctl/container/container_prune_linux_test.go new file mode 100644 index 00000000000..4ff48954385 --- /dev/null +++ b/cmd/nerdctl/container/container_prune_linux_test.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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestPruneContainer(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.Private + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("1")) + helpers.Anyhow("rm", "-f", data.Identifier("2")) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("1"), "-v", "/anonymous", testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("exec", data.Identifier("1"), "touch", "/anonymous/foo") + helpers.Ensure("create", "--name", data.Identifier("2"), testutil.CommonImage, "sleep", nerdtest.Infinity) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("container", "prune", "-f") + helpers.Ensure("inspect", data.Identifier("1")) + helpers.Fail("inspect", data.Identifier("2")) + // https://github.com/containerd/nerdctl/issues/3134 + helpers.Ensure("exec", data.Identifier("1"), "ls", "-lA", "/anonymous/foo") + helpers.Ensure("kill", data.Identifier("1")) + helpers.Ensure("container", "prune", "-f") + return helpers.Command("inspect", data.Identifier("1")) + } + + testCase.Expected = test.Expects(1, nil, nil) +} diff --git a/cmd/nerdctl/container_remove.go b/cmd/nerdctl/container/container_remove.go similarity index 80% rename from cmd/nerdctl/container_remove.go rename to cmd/nerdctl/container/container_remove.go index b6c0d39d472..fd50457a179 100644 --- a/cmd/nerdctl/container_remove.go +++ b/cmd/nerdctl/container/container_remove.go @@ -14,16 +14,19 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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 newRmCommand() *cobra.Command { +func NewRmCommand() *cobra.Command { var rmCommand = &cobra.Command{ Use: "rm [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -33,13 +36,14 @@ func newRmCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } + rmCommand.Aliases = []string{"remove"} rmCommand.Flags().BoolP("force", "f", false, "Force the removal of a running|paused|unknown container (uses SIGKILL)") rmCommand.Flags().BoolP("volumes", "v", false, "Remove volumes associated with the container") return rmCommand } func rmAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -69,5 +73,5 @@ func rmAction(cmd *cobra.Command, args []string) error { func rmShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show container names - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } diff --git a/cmd/nerdctl/container/container_remove_test.go b/cmd/nerdctl/container/container_remove_test.go new file mode 100644 index 00000000000..36c81c2c6ab --- /dev/null +++ b/cmd/nerdctl/container/container_remove_test.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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestRemoveContainer(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + } + + 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 { + containerID := data.Identifier() + helpers.Fail("rm", containerID) + + // FIXME: should (re-)evaluate this + // `kill` seems to return before the container actually stops + helpers.Ensure("stop", containerID) + + return helpers.Command("rm", containerID) + } + + testCase.Expected = test.Expects(0, nil, nil) +} diff --git a/cmd/nerdctl/container_remove_windows_test.go b/cmd/nerdctl/container/container_remove_windows_test.go similarity index 76% rename from cmd/nerdctl/container_remove_windows_test.go rename to cmd/nerdctl/container/container_remove_windows_test.go index 07ed0b44dd3..c6733ca1e9b 100644 --- a/cmd/nerdctl/container_remove_windows_test.go +++ b/cmd/nerdctl/container/container_remove_windows_test.go @@ -14,29 +14,15 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" -) - -func TestRemoveProcessContainer(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - // ignore error - base.Cmd("rm", tID, "-f").AssertOK() - base.Cmd("run", "-d", "--name", tID, testutil.NginxAlpineImage).AssertOK() - defer base.Cmd("rm", tID, "-f").AssertOK() - base.Cmd("rm", tID).AssertFail() - - base.Cmd("kill", tID).AssertOK() - base.Cmd("rm", tID).AssertOK() -} + "github.com/containerd/nerdctl/v2/pkg/testutil" +) func TestRemoveHyperVContainer(t *testing.T) { base := testutil.NewBase(t) diff --git a/cmd/nerdctl/container_rename.go b/cmd/nerdctl/container/container_rename.go similarity index 78% rename from cmd/nerdctl/container_rename.go rename to cmd/nerdctl/container/container_rename.go index 2f1d482a6c1..46b48aae1ed 100644 --- a/cmd/nerdctl/container_rename.go +++ b/cmd/nerdctl/container/container_rename.go @@ -14,19 +14,22 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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 newRenameCommand() *cobra.Command { +func NewRenameCommand() *cobra.Command { var renameCommand = &cobra.Command{ Use: "rename [flags] CONTAINER NEW_NAME", - Args: IsExactArgs(2), + Args: helpers.IsExactArgs(2), Short: "rename a container", RunE: renameAction, ValidArgsFunction: renameShellComplete, @@ -37,7 +40,7 @@ func newRenameCommand() *cobra.Command { } func processContainerRenameOptions(cmd *cobra.Command) (types.ContainerRenameOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerRenameOptions{}, err } @@ -60,5 +63,5 @@ func renameAction(cmd *cobra.Command, args []string) error { return container.Rename(ctx, client, args[0], args[1], options) } func renameShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } diff --git a/cmd/nerdctl/container_rename_linux_test.go b/cmd/nerdctl/container/container_rename_linux_test.go similarity index 88% rename from cmd/nerdctl/container_rename_linux_test.go rename to cmd/nerdctl/container/container_rename_linux_test.go index b082514fe1c..cc8a6733d5f 100644 --- a/cmd/nerdctl/container_rename_linux_test.go +++ b/cmd/nerdctl/container/container_rename_linux_test.go @@ -14,12 +14,13 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestRename(t *testing.T) { @@ -28,7 +29,7 @@ func TestRename(t *testing.T) { base := testutil.NewBase(t) defer base.Cmd("rm", "-f", testContainerName).Run() - base.Cmd("run", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", testContainerName+"_new").Run() base.Cmd("rename", testContainerName, testContainerName+"_new").AssertOK() @@ -44,11 +45,11 @@ func TestRenameUpdateHosts(t *testing.T) { base := testutil.NewBase(t) defer base.Cmd("rm", "-f", testContainerName).Run() - base.Cmd("run", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() base.EnsureContainerStarted(testContainerName) defer base.Cmd("rm", "-f", testContainerName+"_1").Run() - base.Cmd("run", "-d", "--name", testContainerName+"_1", testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", testContainerName+"_1", testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() base.EnsureContainerStarted(testContainerName + "_1") defer base.Cmd("rm", "-f", testContainerName+"_new").Run() diff --git a/cmd/nerdctl/container_rename_windows_test.go b/cmd/nerdctl/container/container_rename_windows_test.go similarity index 87% rename from cmd/nerdctl/container_rename_windows_test.go rename to cmd/nerdctl/container/container_rename_windows_test.go index 38d78cd5473..7532b22573f 100644 --- a/cmd/nerdctl/container_rename_windows_test.go +++ b/cmd/nerdctl/container/container_rename_windows_test.go @@ -14,12 +14,13 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestRenameProcessContainer(t *testing.T) { @@ -27,7 +28,7 @@ func TestRenameProcessContainer(t *testing.T) { base := testutil.NewBase(t) defer base.Cmd("rm", "-f", testContainerName).Run() - base.Cmd("run", "--isolation", "process", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "--isolation", "process", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", testContainerName+"_new").Run() base.Cmd("rename", testContainerName, testContainerName+"_new").AssertOK() @@ -45,7 +46,7 @@ func TestRenameHyperVContainer(t *testing.T) { } defer base.Cmd("rm", "-f", testContainerName).Run() - base.Cmd("run", "--isolation", "hyperv", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "--isolation", "hyperv", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", testContainerName+"_new").Run() base.Cmd("rename", testContainerName, testContainerName+"_new").AssertOK() diff --git a/cmd/nerdctl/container_restart.go b/cmd/nerdctl/container/container_restart.go similarity index 86% rename from cmd/nerdctl/container_restart.go rename to cmd/nerdctl/container/container_restart.go index ae1b8d3d1e3..b1875a77853 100644 --- a/cmd/nerdctl/container_restart.go +++ b/cmd/nerdctl/container/container_restart.go @@ -14,18 +14,20 @@ limitations under the License. */ -package main +package container import ( "time" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/container" ) -func newRestartCommand() *cobra.Command { +func NewRestartCommand() *cobra.Command { var restartCommand = &cobra.Command{ Use: "restart [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -40,7 +42,7 @@ func newRestartCommand() *cobra.Command { } func processContainerRestartOptions(cmd *cobra.Command) (types.ContainerRestartOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerRestartOptions{}, err } diff --git a/cmd/nerdctl/container_restart_linux_test.go b/cmd/nerdctl/container/container_restart_linux_test.go similarity index 65% rename from cmd/nerdctl/container_restart_linux_test.go rename to cmd/nerdctl/container/container_restart_linux_test.go index 669d94d6640..3565f9f11ad 100644 --- a/cmd/nerdctl/container_restart_linux_test.go +++ b/cmd/nerdctl/container/container_restart_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -22,8 +22,10 @@ import ( "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestRestart(t *testing.T) { @@ -51,11 +53,11 @@ func TestRestartPIDContainer(t *testing.T) { base := testutil.NewBase(t) baseContainerName := testutil.Identifier(t) - base.Cmd("run", "-d", "--name", baseContainerName, testutil.AlpineImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", baseContainerName, testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", baseContainerName).Run() sharedContainerName := fmt.Sprintf("%s-shared", baseContainerName) - base.Cmd("run", "-d", "--name", sharedContainerName, fmt.Sprintf("--pid=container:%s", baseContainerName), testutil.AlpineImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", sharedContainerName, fmt.Sprintf("--pid=container:%s", baseContainerName), testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", sharedContainerName).Run() base.Cmd("restart", baseContainerName).AssertOK() @@ -71,12 +73,39 @@ func TestRestartPIDContainer(t *testing.T) { assert.Equal(t, baseOutput, sharedOutput) } +func TestRestartIPCContainer(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + const shmSize = "32m" + baseContainerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", baseContainerName).Run() + base.Cmd("run", "-d", "--shm-size", shmSize, "--ipc", "shareable", "--name", baseContainerName, testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() + + sharedContainerName := fmt.Sprintf("%s-shared", baseContainerName) + defer base.Cmd("rm", "-f", sharedContainerName).Run() + base.Cmd("run", "-d", "--name", sharedContainerName, fmt.Sprintf("--ipc=container:%s", baseContainerName), testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() + + base.Cmd("stop", baseContainerName).Run() + base.Cmd("stop", sharedContainerName).Run() + + base.Cmd("restart", baseContainerName).AssertOK() + base.Cmd("restart", sharedContainerName).AssertOK() + + baseShmSizeResult := base.Cmd("exec", baseContainerName, "/bin/grep", "shm", "/proc/self/mounts").Run() + baseOutput := strings.TrimSpace(baseShmSizeResult.Stdout()) + sharedShmSizeResult := base.Cmd("exec", sharedContainerName, "/bin/grep", "shm", "/proc/self/mounts").Run() + sharedOutput := strings.TrimSpace(sharedShmSizeResult.Stdout()) + + assert.Equal(t, baseOutput, sharedOutput) +} + func TestRestartWithTime(t *testing.T) { t.Parallel() base := testutil.NewBase(t) tID := testutil.Identifier(t) - base.Cmd("run", "-d", "--name", tID, testutil.AlpineImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", tID, testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", tID).AssertOK() inspect := base.InspectContainer(tID) diff --git a/cmd/nerdctl/container_run.go b/cmd/nerdctl/container/container_run.go similarity index 80% rename from cmd/nerdctl/container_run.go rename to cmd/nerdctl/container/container_run.go index fde5e6e023f..855d1512cdf 100644 --- a/cmd/nerdctl/container_run.go +++ b/cmd/nerdctl/container/container_run.go @@ -14,35 +14,40 @@ limitations under the License. */ -package main +package container import ( "errors" "fmt" "runtime" + "strings" - "github.com/containerd/console" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/consoleutil" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/logging" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/signalutil" - "github.com/containerd/nerdctl/pkg/taskutil" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + "github.com/containerd/console" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/pkg/annotations" + "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/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/labels" + "github.com/containerd/nerdctl/v2/pkg/logging" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/signalutil" + "github.com/containerd/nerdctl/v2/pkg/taskutil" ) const ( tiniInitBinary = "tini" ) -func newRunCommand() *cobra.Command { +func NewRunCommand() *cobra.Command { shortHelp := "Run a command in a new container. Optionally specify \"ipfs://\" or \"ipns://\" scheme to pull image from IPFS." longHelp := shortHelp switch runtime.GOOS { @@ -68,6 +73,7 @@ func newRunCommand() *cobra.Command { setCreateFlags(runCommand) runCommand.Flags().BoolP("detach", "d", false, "Run container in background and print container ID") + runCommand.Flags().StringSliceP("attach", "a", []string{}, "Attach STDIN, STDOUT, or STDERR") return runCommand } @@ -78,6 +84,7 @@ func setCreateFlags(cmd *cobra.Command) { cmd.Flags().Bool("help", false, "show help") cmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY") + cmd.Flags().Bool("sig-proxy", true, "Proxy received signals to the process (default true)") cmd.Flags().BoolP("interactive", "i", false, "Keep STDIN open even if not attached") cmd.Flags().String("restart", "no", `Restart policy to apply when a container exits (implemented values: "no"|"always|on-failure:n|unless-stopped")`) cmd.RegisterFlagCompletionFunc("restart", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -85,6 +92,7 @@ func setCreateFlags(cmd *cobra.Command) { }) cmd.Flags().Bool("rm", false, "Automatically remove the container when it exits") cmd.Flags().String("pull", "missing", `Pull image before running ("always"|"missing"|"never")`) + cmd.Flags().BoolP("quiet", "q", false, "Suppress the pull output") cmd.RegisterFlagCompletionFunc("pull", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"always", "missing", "never"}, cobra.ShellCompDirectiveNoFileComp }) @@ -99,18 +107,18 @@ func setCreateFlags(cmd *cobra.Command) { // #region platform flags cmd.Flags().String("platform", "", "Set platform (e.g. \"amd64\", \"arm64\")") // not a slice, and there is no --all-platforms - cmd.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + cmd.RegisterFlagCompletionFunc("platform", completion.Platforms) // #endregion // #region network flags // network (net) is defined as StringSlice, not StringArray, to allow specifying "--network=cni1,cni2" - cmd.Flags().StringSlice("network", []string{netutil.DefaultNetworkName}, `Connect a container to a network ("bridge"|"host"|"none"|"container:"|)`) + cmd.Flags().StringSlice("network", []string{netutil.DefaultNetworkName}, `Connect a container to a network ("bridge"|"host"|"none"|"container:"|"ns:"|)`) cmd.RegisterFlagCompletionFunc("network", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteNetworkNames(cmd, []string{}) + return completion.NetworkNames(cmd, []string{}) }) - cmd.Flags().StringSlice("net", []string{netutil.DefaultNetworkName}, `Connect a container to a network ("bridge"|"host"|"none"|)`) + cmd.Flags().StringSlice("net", []string{netutil.DefaultNetworkName}, `Connect a container to a network ("bridge"|"host"|"none"|"container:"|"ns:"|)`) cmd.RegisterFlagCompletionFunc("net", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteNetworkNames(cmd, []string{}) + return completion.NetworkNames(cmd, []string{}) }) // dns is defined as StringSlice, not StringArray, to allow specifying "--dns=1.1.1.1,8.8.8.8" (compatible with Podman) cmd.Flags().StringSlice("dns", nil, "Set custom DNS servers") @@ -120,8 +128,8 @@ func setCreateFlags(cmd *cobra.Command) { cmd.Flags().StringSlice("dns-option", nil, "Set DNS options") // publish is defined as StringSlice, not StringArray, to allow specifying "--publish=80:80,443:443" (compatible with Podman) cmd.Flags().StringSliceP("publish", "p", nil, "Publish a container's port(s) to the host") - // FIXME: not support IPV6 yet cmd.Flags().String("ip", "", "IPv4 address to assign to the container") + cmd.Flags().String("ip6", "", "IPv6 address to assign to the container") cmd.Flags().StringP("hostname", "h", "", "Container host name") cmd.Flags().String("mac-address", "", "MAC address to assign to the container") // #endregion @@ -172,7 +180,12 @@ func setCreateFlags(cmd *cobra.Command) { // #region security flags cmd.Flags().StringArray("security-opt", []string{}, "Security options") cmd.RegisterFlagCompletionFunc("security-opt", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"seccomp=", "seccomp=unconfined", "apparmor=", "apparmor=" + defaults.AppArmorProfileName, "apparmor=unconfined", "no-new-privileges", "privileged-without-host-devices"}, cobra.ShellCompDirectiveNoFileComp + return []string{ + "seccomp=", "seccomp=" + defaults.SeccompProfileName, "seccomp=unconfined", + "apparmor=", "apparmor=" + defaults.AppArmorProfileName, "apparmor=unconfined", + "no-new-privileges", + "systempaths=unconfined", + "privileged-without-host-devices"}, cobra.ShellCompDirectiveNoFileComp }) // cap-add and cap-drop are defined as StringSlice, not StringArray, to allow specifying "--cap-add=CAP_SYS_ADMIN,CAP_NET_ADMIN" (compatible with Podman) cmd.Flags().StringSlice("cap-add", []string{}, "Add Linux capabilities") @@ -180,6 +193,7 @@ func setCreateFlags(cmd *cobra.Command) { cmd.Flags().StringSlice("cap-drop", []string{}, "Drop Linux capabilities") cmd.RegisterFlagCompletionFunc("cap-drop", capShellComplete) cmd.Flags().Bool("privileged", false, "Give extended privileges to this container") + cmd.Flags().String("systemd", "false", "Allow running systemd in this container (default: false)") // #endregion // #region runtime flags @@ -199,6 +213,8 @@ func setCreateFlags(cmd *cobra.Command) { // tmpfs needs to be StringArray, not StringSlice, to prevent "/foo:size=64m,exec" from being split to {"/foo:size=64m", "exec"} cmd.Flags().StringArray("tmpfs", nil, "Mount a tmpfs directory") cmd.Flags().StringArray("mount", nil, "Attach a filesystem mount to the container") + // volumes-from needs to be StringArray, not StringSlice, to prevent "id1,id2" from being split to {"id1", "id2"} (compatible with Docker) + cmd.Flags().StringArray("volumes-from", nil, "Mount volumes from the specified container(s)") // #endregion // rootfs flags @@ -223,8 +239,10 @@ func setCreateFlags(cmd *cobra.Command) { cmd.Flags().String("name", "", "Assign a name to the container") // label needs to be StringArray, not StringSlice, to prevent "foo=foo1,foo2" from being split to {"foo=foo1", "foo2"} cmd.Flags().StringArrayP("label", "l", nil, "Set metadata on container") - cmd.RegisterFlagCompletionFunc("label", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return labels.ShellCompletions, cobra.ShellCompDirectiveNoFileComp + // annotation needs to be StringArray, not StringSlice, to prevent "foo=foo1,foo2" from being split to {"foo=foo1", "foo2"} + cmd.Flags().StringArray("annotation", nil, "Add an annotation to the container (passed through to the OCI runtime)") + cmd.RegisterFlagCompletionFunc("annotation", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return annotations.ShellCompletions, cobra.ShellCompDirectiveNoFileComp }) // label-file is defined as StringSlice, not StringArray, to allow specifying "--env-file=FILE1,FILE2" (compatible with Podman) @@ -269,32 +287,55 @@ func setCreateFlags(cmd *cobra.Command) { } -func processCreateCommandFlagsInRun(cmd *cobra.Command) (opt types.ContainerCreateOptions, err error) { - opt, err = processContainerCreateOptions(cmd) +func processCreateCommandFlagsInRun(cmd *cobra.Command) (types.ContainerCreateOptions, error) { + opt, err := processContainerCreateOptions(cmd) if err != nil { - return + return opt, err } opt.InRun = true + opt.SigProxy, err = cmd.Flags().GetBool("sig-proxy") + if err != nil { + return opt, err + } opt.Interactive, err = cmd.Flags().GetBool("interactive") if err != nil { - return + return opt, err } opt.Detach, err = cmd.Flags().GetBool("detach") if err != nil { - return + return opt, err } opt.DetachKeys, err = cmd.Flags().GetString("detach-keys") if err != nil { - return + return opt, err + } + opt.Attach, err = cmd.Flags().GetStringSlice("attach") + if err != nil { + return opt, err + } + + validAttachFlag := true + for i, str := range opt.Attach { + opt.Attach[i] = strings.ToUpper(str) + + if opt.Attach[i] != "STDIN" && opt.Attach[i] != "STDOUT" && opt.Attach[i] != "STDERR" { + validAttachFlag = false + } + } + if !validAttachFlag { + return opt, fmt.Errorf("invalid stream specified with -a flag. Valid streams are STDIN, STDOUT, and STDERR") } + return opt, nil } // runAction is heavily based on ctr implementation: // https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/run/run.go func runAction(cmd *cobra.Command, args []string) error { + var isDetached bool + createOpt, err := processCreateCommandFlagsInRun(cmd) if err != nil { return err @@ -310,12 +351,16 @@ func runAction(cmd *cobra.Command, args []string) error { return errors.New("flags -d and --rm cannot be specified together") } + if len(createOpt.Attach) > 0 && createOpt.Detach { + return errors.New("flags -d and -a cannot be specified together") + } + netFlags, err := loadNetworkFlags(cmd) if err != nil { return fmt.Errorf("failed to load networking flags: %s", err) } - netManager, err := containerutil.NewNetworkingOptionsManager(createOpt.GOptions, netFlags) + netManager, err := containerutil.NewNetworkingOptionsManager(createOpt.GOptions, netFlags, client) if err != nil { return err } @@ -335,25 +380,26 @@ func runAction(cmd *cobra.Command, args []string) error { }() id := c.ID() - if createOpt.Rm && !createOpt.Detach { + if createOpt.Rm { defer func() { - // NOTE: OCI hooks (which are used for CNI network setup/teardown on Linux) - // are not currently supported on Windows, so we must explicitly call - // network setup/cleanup from the main nerdctl executable. - if runtime.GOOS == "windows" { - if err := netManager.CleanupNetworking(ctx, c); err != nil { - logrus.Warnf("failed to clean up container networking: %s", err) - } + if isDetached { + return + } + if err := netManager.CleanupNetworking(ctx, c); err != nil { + log.L.Warnf("failed to clean up container networking: %s", err) } - if err := container.RemoveContainer(ctx, c, createOpt.GOptions, true, true); err != nil { - logrus.WithError(err).Warnf("failed to remove container %s", id) + if err := container.RemoveContainer(ctx, c, createOpt.GOptions, true, true, client); err != nil { + log.L.WithError(err).Warnf("failed to remove container %s", id) } }() } var con console.Console if createOpt.TTY && !createOpt.Detach { - con = console.Current() + con, err = consoleutil.Current() + if err != nil { + return err + } defer con.Reset() if err := con.SetRaw(); err != nil { return err @@ -366,8 +412,8 @@ func runAction(cmd *cobra.Command, args []string) error { } logURI := lab[labels.LogURI] detachC := make(chan struct{}) - task, err := taskutil.NewTask(ctx, client, c, false, createOpt.Interactive, createOpt.TTY, createOpt.Detach, - con, logURI, createOpt.DetachKeys, detachC) + task, err := taskutil.NewTask(ctx, client, c, createOpt.Attach, createOpt.Interactive, createOpt.TTY, createOpt.Detach, + con, logURI, createOpt.DetachKeys, createOpt.GOptions.Namespace, detachC) if err != nil { return err } @@ -376,16 +422,18 @@ func runAction(cmd *cobra.Command, args []string) error { } if createOpt.Detach { - fmt.Fprintf(createOpt.Stdout, "%s\n", id) + fmt.Fprintln(createOpt.Stdout, id) return nil } if createOpt.TTY { if err := consoleutil.HandleConsoleResize(ctx, task, con); err != nil { - logrus.WithError(err).Error("console resize") + log.L.WithError(err).Error("console resize") } } else { - sigC := signalutil.ForwardAllSignals(ctx, task) - defer signalutil.StopCatch(sigC) + if createOpt.SigProxy { + sigC := signalutil.ForwardAllSignals(ctx, task) + defer signalutil.StopCatch(sigC) + } } statusC, err := task.Wait(ctx) @@ -406,10 +454,11 @@ func runAction(cmd *cobra.Command, args []string) error { return errors.New("got a nil IO from the task") } io.Wait() + isDetached = true case status := <-statusC: if createOpt.Rm { if _, taskDeleteErr := task.Delete(ctx); taskDeleteErr != nil { - logrus.Error(taskDeleteErr) + log.L.Error(taskDeleteErr) } } code, _, err := status.Result() @@ -422,3 +471,10 @@ func runAction(cmd *cobra.Command, args []string) error { } return nil } + +func runShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.ImageNames(cmd) + } + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/nerdctl/container_run_cgroup_linux_test.go b/cmd/nerdctl/container/container_run_cgroup_linux_test.go similarity index 76% rename from cmd/nerdctl/container_run_cgroup_linux_test.go rename to cmd/nerdctl/container/container_run_cgroup_linux_test.go index 0301ae58e66..c4b5c77de7c 100644 --- a/cmd/nerdctl/container_run_cgroup_linux_test.go +++ b/cmd/nerdctl/container/container_run_cgroup_linux_test.go @@ -14,21 +14,28 @@ limitations under the License. */ -package main +package container import ( "bytes" + "context" "fmt" "os" "path/filepath" "testing" + "github.com/moby/sys/userns" + "gotest.tools/v3/assert" + "github.com/containerd/cgroups/v3" - "github.com/containerd/containerd/pkg/userns" + containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/continuity/testutil/loopback" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" ) func TestRunCgroupV2(t *testing.T) { @@ -94,7 +101,7 @@ func TestRunCgroupV2(t *testing.T) { "cpu.weight", "cpuset.cpus", "cpuset.mems").AssertOutExactly(expected2) base.Cmd("run", "--name", testutil.Identifier(t)+"-testUpdate1", "-w", "/sys/fs/cgroup", "-d", - testutil.AlpineImage, "sleep", "infinity").AssertOK() + 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", @@ -113,7 +120,7 @@ func TestRunCgroupV2(t *testing.T) { defer base.Cmd("rm", "-f", testutil.Identifier(t)+"-testUpdate2").Run() base.Cmd("run", "--name", testutil.Identifier(t)+"-testUpdate2", "-w", "/sys/fs/cgroup", "-d", - testutil.AlpineImage, "sleep", "infinity").AssertOK() + testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() base.EnsureContainerStarted(testutil.Identifier(t) + "-testUpdate2") base.Cmd("update", "--cpu-quota", "42000", "--cpuset-mems", "0", "--cpu-period", "100000", @@ -167,6 +174,53 @@ func TestRunCgroupV1(t *testing.T) { 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) } +// TestIssue3781 tests https://github.com/containerd/nerdctl/issues/3781 +func TestIssue3781(t *testing.T) { + t.Parallel() + testCase := nerdtest.Setup() + testCase.Require = test.Not(nerdtest.Docker) + + base := testutil.NewBase(t) + info := base.Info() + switch info.CgroupDriver { + case "none", "": + t.Skip("test requires cgroup driver") + } + containerName := testutil.Identifier(t) + base.Cmd("run", "-d", "--name", containerName, testutil.AlpineImage, "sleep", "infinity").AssertOK() + defer func() { + base.Cmd("rm", "-f", containerName) + }() + base.Cmd("update", "--cpuset-cpus", "0-1", containerName).AssertOK() + addr := base.ContainerdAddress() + client, err := containerd.New(addr, containerd.WithDefaultNamespace(testutil.Namespace)) + assert.NilError(base.T, err) + ctx := context.Background() + + // get container id by container name. + var cid string + var args []string + args = append(args, containerName) + 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) + } + cid = found.Container.ID() + return nil + }, + } + err = walker.WalkAll(ctx, args, true) + assert.NilError(base.T, err) + + container, err := client.LoadContainer(ctx, cid) + assert.NilError(base.T, err) + spec, err := container.Spec(ctx) + assert.NilError(base.T, err) + assert.Equal(t, spec.Linux.Resources.Pids == nil, true) +} + func TestRunDevice(t *testing.T) { if os.Geteuid() != 0 || userns.RunningInUserNS() { t.Skip("test requires the root in the initial user namespace") @@ -197,7 +251,7 @@ func TestRunDevice(t *testing.T) { "--name", containerName, "--device", lo[0].Device+":r", "--device", lo[1].Device, - testutil.AlpineImage, "sleep", "infinity").Run() + testutil.AlpineImage, "sleep", nerdtest.Infinity).Run() base.Cmd("exec", containerName, "cat", lo[0].Device).AssertOutContains(loContent[0]) base.Cmd("exec", containerName, "cat", lo[1].Device).AssertOutContains(loContent[1]) @@ -212,39 +266,46 @@ func TestRunDevice(t *testing.T) { func TestParseDevice(t *testing.T) { t.Parallel() type testCase struct { - s string - expectedDevPath string - expectedMode string - err string + s string + expectedDevPath string + expectedContainerPath string + expectedMode string + err string } testCases := []testCase{ { - s: "/dev/sda1", - expectedDevPath: "/dev/sda1", - expectedMode: "rwm", + s: "/dev/sda1", + expectedDevPath: "/dev/sda1", + expectedContainerPath: "/dev/sda1", + expectedMode: "rwm", }, { - s: "/dev/sda2:r", - expectedDevPath: "/dev/sda2", - expectedMode: "r", + s: "/dev/sda2:r", + expectedDevPath: "/dev/sda2", + expectedContainerPath: "/dev/sda2", + expectedMode: "r", }, { - s: "/dev/sda3:rw", - expectedDevPath: "/dev/sda3", - expectedMode: "rw", + s: "/dev/sda3:rw", + expectedDevPath: "/dev/sda3", + expectedContainerPath: "/dev/sda3", + expectedMode: "rw", }, { s: "sda4", err: "not an absolute path", }, { - s: "/dev/sda5:/dev/sda5", - expectedDevPath: "/dev/sda5", - expectedMode: "rwm", + s: "/dev/sda5:/dev/sda5", + expectedDevPath: "/dev/sda5", + expectedContainerPath: "/dev/sda5", + expectedMode: "rwm", }, { - s: "/dev/sda6:/dev/foo6", - err: "not supported yet", + s: "/dev/sda6:/dev/foo6", + expectedDevPath: "/dev/sda6", + expectedContainerPath: "/dev/foo6", + expectedMode: "rwm", }, { s: "/dev/sda7:/dev/sda7:rwmx", @@ -254,10 +315,11 @@ func TestParseDevice(t *testing.T) { for _, tc := range testCases { t.Log(tc.s) - devPath, mode, err := container.ParseDevice(tc.s) + devPath, containerPath, mode, err := container.ParseDevice(tc.s) if tc.err == "" { assert.NilError(t, err) assert.Equal(t, tc.expectedDevPath, devPath) + assert.Equal(t, tc.expectedContainerPath, containerPath) assert.Equal(t, tc.expectedMode, mode) } else { assert.ErrorContains(t, err, tc.err) @@ -288,14 +350,12 @@ func TestRunCgroupParent(t *testing.T) { t.Parallel() base := testutil.NewBase(t) info := base.Info() - containerName := testutil.Identifier(t) - defer base.Cmd("rm", "-f", containerName).Run() - switch info.CgroupDriver { case "none", "": t.Skip("test requires cgroup driver") } + containerName := testutil.Identifier(t) t.Logf("Using %q cgroup driver", info.CgroupDriver) parent := "/foobarbaz" @@ -306,6 +366,13 @@ func TestRunCgroupParent(t *testing.T) { parent = "foobarbaz.slice" } + tearDown := func() { + base.Cmd("rm", "-f", containerName).Run() + } + + tearDown() + t.Cleanup(tearDown) + // cgroup2 without host cgroup ns will just output 0::/ which doesn't help much to verify // we got our expected path. This approach should work for both cgroup1 and 2, there will // just be many more entries for cgroup1 as there'll be an entry per controller. @@ -325,6 +392,9 @@ func TestRunCgroupParent(t *testing.T) { expected := filepath.Join(parent, id) if info.CgroupDriver == "systemd" { expected = filepath.Join(parent, fmt.Sprintf("nerdctl-%s", id)) + if base.Target == testutil.Docker { + expected = filepath.Join(parent, fmt.Sprintf("docker-%s", id)) + } } base.Cmd("exec", containerName, "cat", "/proc/self/cgroup").AssertOutContains(expected) } @@ -346,7 +416,7 @@ func TestRunBlkioWeightCgroupV2(t *testing.T) { containerName := testutil.Identifier(t) defer base.Cmd("rm", "-f", containerName).AssertOK() // when bfq io scheduler is used, the io.weight knob is exposed as io.bfq.weight - base.Cmd("run", "--name", containerName, "--blkio-weight", "300", "-w", "/sys/fs/cgroup", testutil.AlpineImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "--name", containerName, "--blkio-weight", "300", "-w", "/sys/fs/cgroup", testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() base.Cmd("exec", containerName, "cat", "io.bfq.weight").AssertOutExactly("default 300\n") base.Cmd("update", containerName, "--blkio-weight", "400").AssertOK() base.Cmd("exec", containerName, "cat", "io.bfq.weight").AssertOutExactly("default 400\n") diff --git a/cmd/nerdctl/container_run_freebsd.go b/cmd/nerdctl/container/container_run_freebsd.go similarity index 82% rename from cmd/nerdctl/container_run_freebsd.go rename to cmd/nerdctl/container/container_run_freebsd.go index e93f88cba9f..5ef9a6d94fb 100644 --- a/cmd/nerdctl/container_run_freebsd.go +++ b/cmd/nerdctl/container/container_run_freebsd.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "github.com/spf13/cobra" @@ -24,7 +24,3 @@ func capShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]s candidates := []string{} return candidates, cobra.ShellCompDirectiveNoFileComp } - -func runShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/nerdctl/container_run_gpus_test.go b/cmd/nerdctl/container/container_run_gpus_test.go similarity index 95% rename from cmd/nerdctl/container_run_gpus_test.go rename to cmd/nerdctl/container/container_run_gpus_test.go index b9961aeccb5..820acd8637e 100644 --- a/cmd/nerdctl/container_run_gpus_test.go +++ b/cmd/nerdctl/container/container_run_gpus_test.go @@ -14,14 +14,15 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/cmd/container" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" + + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) func TestParseGpusOptAll(t *testing.T) { diff --git a/cmd/nerdctl/container_run_linux.go b/cmd/nerdctl/container/container_run_linux.go similarity index 78% rename from cmd/nerdctl/container_run_linux.go rename to cmd/nerdctl/container/container_run_linux.go index 6ee1eeb2f9f..a84bedddfdf 100644 --- a/cmd/nerdctl/container_run_linux.go +++ b/cmd/nerdctl/container/container_run_linux.go @@ -14,13 +14,14 @@ limitations under the License. */ -package main +package container import ( "strings" - "github.com/containerd/containerd/pkg/cap" "github.com/spf13/cobra" + + "github.com/containerd/containerd/v2/pkg/cap" ) func capShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -32,10 +33,3 @@ func capShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]s } return candidates, cobra.ShellCompDirectiveNoFileComp } - -func runShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) == 0 { - return shellCompleteImageNames(cmd) - } - return nil, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/nerdctl/container_run_linux_test.go b/cmd/nerdctl/container/container_run_linux_test.go similarity index 59% rename from cmd/nerdctl/container_run_linux_test.go rename to cmd/nerdctl/container/container_run_linux_test.go index 678ea108d46..dc33702e1bd 100644 --- a/cmd/nerdctl/container_run_linux_test.go +++ b/cmd/nerdctl/container/container_run_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "bufio" @@ -26,23 +26,39 @@ import ( "net/http" "os" "path/filepath" - "runtime" "strconv" "strings" + "syscall" "testing" "time" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" ) func TestRunCustomRootfs(t *testing.T) { testutil.DockerIncompatible(t) - base := testutil.NewBase(t) + // FIXME: root issue is undiagnosed and this is very likely a containerd bug + // It appears that in certain conditions, the proxy content store info method will fail on the layer of the image + // Search for func (pcs *proxyContentStore) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { + // Note that: + // - the problem is still here with containerd and nerdctl v2 + // - it seems to affect images that are tagged multiple times, or that share a layer with another image + // - this test is not parallelized - but the fact that namespacing it solves the problem suggest that something + // happening in the default namespace BEFORE this test is run is SOMETIMES setting conditions that will make this fail + // Possible suspects would be concurrent pulls somehow effing things up w. namespaces. + base := testutil.NewBaseWithNamespace(t, testutil.Identifier(t)) rootfs := prepareCustomRootfs(base, testutil.AlpineImage) + t.Cleanup(func() { + base.Cmd("namespace", "remove", testutil.Identifier(t)).Run() + }) defer os.RemoveAll(rootfs) base.Cmd("run", "--rm", "--rootfs", rootfs, "/bin/cat", "/proc/self/environ").AssertOutContains("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") base.Cmd("run", "--rm", "--entrypoint", "/bin/echo", "--rootfs", rootfs, "echo", "foo").AssertOutExactly("echo foo\n") @@ -57,7 +73,7 @@ func prepareCustomRootfs(base *testutil.Base, imageName string) string { base.Cmd("save", "-o", archiveTarPath, imageName).AssertOK() rootfs, err := os.MkdirTemp(base.T.TempDir(), "rootfs") assert.NilError(base.T, err) - err = extractDockerArchive(archiveTarPath, rootfs) + err = helpers.ExtractDockerArchive(archiveTarPath, rootfs) assert.NilError(base.T, err) return rootfs } @@ -70,6 +86,64 @@ func TestRunShmSize(t *testing.T) { base.Cmd("run", "--rm", "--shm-size", shmSize, testutil.AlpineImage, "/bin/grep", "shm", "/proc/self/mounts").AssertOutContains("size=32768k") } +func TestRunShmSizeIPCShareable(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + const shmSize = "32m" + + container := testutil.Identifier(t) + base.Cmd("run", "--rm", "--name", container, "--ipc", "shareable", "--shm-size", shmSize, testutil.AlpineImage, "/bin/grep", "shm", "/proc/self/mounts").AssertOutContains("size=32768k") + defer base.Cmd("rm", "-f", container) +} + +func TestRunIPCShareableRemoveMount(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + container := testutil.Identifier(t) + + base.Cmd("run", "--name", container, "--ipc", "shareable", testutil.AlpineImage, "sleep", "0").AssertOK() + base.Cmd("rm", container).AssertOK() +} + +func TestRunIPCContainerNotExists(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + container := testutil.Identifier(t) + result := base.Cmd("run", "--name", container, "--ipc", "container:abcd1234", testutil.AlpineImage, "sleep", nerdtest.Infinity).Run() + defer base.Cmd("rm", "-f", container) + combined := result.Combined() + if !strings.Contains(strings.ToLower(combined), "no such container: abcd1234") { + t.Fatalf("unexpected output: %s", combined) + } +} + +func TestRunShmSizeIPCContainer(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + const shmSize = "32m" + sharedContainerResult := base.Cmd("run", "-d", "--ipc", "shareable", "--shm-size", shmSize, testutil.AlpineImage, "sleep", nerdtest.Infinity).Run() + baseContainerID := strings.TrimSpace(sharedContainerResult.Stdout()) + defer base.Cmd("rm", "-f", baseContainerID).Run() + + base.Cmd("run", "--rm", fmt.Sprintf("--ipc=container:%s", baseContainerID), + testutil.AlpineImage, "/bin/grep", "shm", "/proc/self/mounts").AssertOutContains("size=32768k") +} + +func TestRunIPCContainer(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + const shmSize = "32m" + victimContainerResult := base.Cmd("run", "-d", "--ipc", "shareable", "--shm-size", shmSize, testutil.AlpineImage, "sleep", nerdtest.Infinity).Run() + victimContainerID := strings.TrimSpace(victimContainerResult.Stdout()) + defer base.Cmd("rm", "-f", victimContainerID).Run() + + base.Cmd("run", "--rm", fmt.Sprintf("--ipc=container:%s", victimContainerID), + testutil.AlpineImage, "/bin/grep", "shm", "/proc/self/mounts").AssertOutContains("size=32768k") +} + func TestRunPidHost(t *testing.T) { t.Parallel() base := testutil.NewBase(t) @@ -97,12 +171,12 @@ func TestRunPidContainer(t *testing.T) { t.Parallel() base := testutil.NewBase(t) - sharedContainerResult := base.Cmd("run", "-d", testutil.AlpineImage, "sleep", "infinity").Run() + sharedContainerResult := base.Cmd("run", "-d", testutil.AlpineImage, "sleep", nerdtest.Infinity).Run() baseContainerID := strings.TrimSpace(sharedContainerResult.Stdout()) defer base.Cmd("rm", "-f", baseContainerID).Run() base.Cmd("run", "--rm", fmt.Sprintf("--pid=container:%s", baseContainerID), - testutil.AlpineImage, "ps", "ax").AssertOutContains("sleep infinity") + testutil.AlpineImage, "ps", "ax").AssertOutContains("sleep " + nerdtest.Infinity) } func TestRunIpcHost(t *testing.T) { @@ -206,7 +280,7 @@ func TestRunWithInit(t *testing.T) { base := testutil.NewBase(t) container := testutil.Identifier(t) - base.Cmd("run", "-d", "--name", container, testutil.AlpineImage, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", container, testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", container).Run() base.Cmd("stop", "--time=3", container).AssertOK() @@ -216,7 +290,7 @@ func TestRunWithInit(t *testing.T) { // Test with --init-path container1 := container + "-1" base.Cmd("run", "-d", "--name", container1, "--init-binary", "tini-custom", - testutil.AlpineImage, "sleep", "infinity").AssertOK() + testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", container1).Run() base.Cmd("stop", "--time=3", container1).AssertOK() @@ -225,7 +299,7 @@ func TestRunWithInit(t *testing.T) { // Test with --init container2 := container + "-2" base.Cmd("run", "-d", "--name", container2, "--init", - testutil.AlpineImage, "sleep", "infinity").AssertOK() + testutil.AlpineImage, "sleep", nerdtest.Infinity).AssertOK() defer base.Cmd("rm", "-f", container2).Run() base.Cmd("stop", "--time=3", container2).AssertOK() @@ -250,13 +324,84 @@ func TestRunTTY(t *testing.T) { // tests pipe works res := icmd.RunCmd(icmd.Command("unbuffer", "/bin/sh", "-c", fmt.Sprintf("%q run --rm -it %q echo hi | grep hi", base.Binary, testutil.CommonImage))) - assert.Equal(t, 0, res.ExitCode, res.Combined()) + assert.Equal(t, 0, res.ExitCode, res) } -func TestRunWithFluentdLogDriver(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("fluentd log driver is not yet implemented on Windows") +func runSigProxy(t *testing.T, args ...string) (string, bool, bool) { + t.Parallel() + base := testutil.NewBase(t) + testContainerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", testContainerName).Run() + + fullArgs := []string{"run"} + fullArgs = append(fullArgs, args...) + fullArgs = append(fullArgs, + "--name", + testContainerName, + testutil.CommonImage, + "sh", + "-c", + testutil.SigProxyTestScript, + ) + + result := base.Cmd(fullArgs...).Start() + process := result.Cmd.Process + + // Waits until we reach the trap command in the shell script, then sends SIGINT. + time.Sleep(3 * time.Second) + syscall.Kill(process.Pid, syscall.SIGINT) + + // Waits until SIGINT is sent and responded to, then kills process to avoid timeout + time.Sleep(3 * time.Second) + process.Kill() + + sigIntRecieved := strings.Contains(result.Stdout(), testutil.SigProxyTrueOut) + timedOut := strings.Contains(result.Stdout(), testutil.SigProxyTimeoutMsg) + + return result.Stdout(), sigIntRecieved, timedOut +} + +func TestRunSigProxy(t *testing.T) { + + type testCase struct { + name string + args []string + want bool + expectedOut string + } + testCases := []testCase{ + { + name: "SigProxyDefault", + args: []string{}, + want: true, + expectedOut: testutil.SigProxyTrueOut, + }, + { + name: "SigProxyTrue", + args: []string{"--sig-proxy=true"}, + want: true, + expectedOut: testutil.SigProxyTrueOut, + }, + { + name: "SigProxyFalse", + args: []string{"--sig-proxy=false"}, + want: false, + expectedOut: "", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + stdout, sigIntRecieved, timedOut := runSigProxy(t, tc.args...) + errorMsg := fmt.Sprintf("%s failed;\nExpected: '%s'\nActual: '%s'", tc.name, tc.expectedOut, stdout) + assert.Equal(t, false, timedOut, errorMsg) + assert.Equal(t, tc.want, sigIntRecieved, errorMsg) + }) } +} + +func TestRunWithFluentdLogDriver(t *testing.T) { base := testutil.NewBase(t) tempDirectory := t.TempDir() err := os.Chmod(tempDirectory, 0777) @@ -286,9 +431,6 @@ func TestRunWithFluentdLogDriver(t *testing.T) { } func TestRunWithFluentdLogDriverWithLogOpt(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("fluentd log driver is not yet implemented on Windows") - } base := testutil.NewBase(t) tempDirectory := t.TempDir() err := os.Chmod(tempDirectory, 0777) @@ -358,3 +500,70 @@ func TestRunWithDetachKeys(t *testing.T) { container := base.InspectContainer(containerName) assert.Equal(base.T, container.State.Running, true) } + +func TestRunWithTtyAndDetached(t *testing.T) { + base := testutil.NewBase(t) + imageName := testutil.CommonImage + withoutTtyContainerName := "without-terminal-" + testutil.Identifier(t) + withTtyContainerName := "with-terminal-" + testutil.Identifier(t) + + // without -t, fail + base.Cmd("run", "-d", "--name", withoutTtyContainerName, imageName, "stty").AssertOK() + defer base.Cmd("container", "rm", "-f", withoutTtyContainerName).AssertOK() + base.Cmd("logs", withoutTtyContainerName).AssertCombinedOutContains("stty: standard input: Not a tty") + withoutTtyContainer := base.InspectContainer(withoutTtyContainerName) + assert.Equal(base.T, 1, withoutTtyContainer.State.ExitCode) + + // with -t, success + base.Cmd("run", "-d", "-t", "--name", withTtyContainerName, imageName, "stty").AssertOK() + defer base.Cmd("container", "rm", "-f", withTtyContainerName).AssertOK() + base.Cmd("logs", withTtyContainerName).AssertCombinedOutContains("speed 38400 baud; line = 0;") + withTtyContainer := base.InspectContainer(withTtyContainerName) + assert.Equal(base.T, 0, withTtyContainer.State.ExitCode) +} + +// TestIssue3568 tests https://github.com/containerd/nerdctl/issues/3568 +func TestIssue3568(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #3568 - Detaching from a container started by using --rm option causes the container to be deleted.", + // When detaching from a container, for a session started with 'docker attach', it prints 'read escape sequence', but for one started with 'docker (run|start)', it prints nothing. + // However, the flag is called '--detach-keys' in all cases, so nerdctl prints 'read detach keys' for all cases, and that's why this test is skipped for Docker. + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("run", "--rm", "-it", "--detach-keys=ctrl-a,ctrl-b", "--name", data.Identifier(), testutil.CommonImage) + // unbuffer(1) can be installed with `apt-get install expect`. + // + // "-p" is needed because we need unbuffer to read from stdin, and from [1]: + // "Normally, unbuffer does not read from stdin. This simplifies use of unbuffer in some situations. + // To use unbuffer in a pipeline, use the -p flag." + // + // [1] https://linux.die.net/man/1/unbuffer + cmd.WithWrapper("unbuffer", "-p") + cmd.WithStdin(testutil.NewDelayOnceReader(bytes.NewReader([]byte{1, 2}))) // https://www.physics.udel.edu/~watson/scen103/ascii.html + return cmd + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: test.All( + test.Contains("read detach keys"), + func(stdout string, info string, t *testing.T) { + assert.Assert(t, strings.Contains(helpers.Capture("ps"), data.Identifier())) + }, + ), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_run_log_driver_syslog_test.go b/cmd/nerdctl/container/container_run_log_driver_syslog_test.go similarity index 96% rename from cmd/nerdctl/container_run_log_driver_syslog_test.go rename to cmd/nerdctl/container/container_run_log_driver_syslog_test.go index 678006362b9..36bf6c046d5 100644 --- a/cmd/nerdctl/container_run_log_driver_syslog_test.go +++ b/cmd/nerdctl/container/container_run_log_driver_syslog_test.go @@ -14,21 +14,23 @@ limitations under the License. */ -package main +package container import ( "fmt" "os" "runtime" + "strconv" "strings" "testing" "time" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testca" - "github.com/containerd/nerdctl/pkg/testutil/testsyslog" syslog "github.com/yuchanns/srslog" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/testca" + "github.com/containerd/nerdctl/v2/pkg/testutil/testsyslog" ) func runSyslogTest(t *testing.T, networks []string, syslogFacilities map[string]syslog.Priority, fmtValidFuncs map[string]func(string, string, string, string, syslog.Priority, bool) error) { @@ -53,7 +55,7 @@ func runSyslogTest(t *testing.T, networks []string, syslogFacilities map[string] for rFK, rFV := range syslogFacilities { fPriV := rFV // test both string and number facility - for _, fPriK := range []string{rFK, fmt.Sprintf("%d", int(fPriV)>>3)} { + for _, fPriK := range []string{rFK, strconv.Itoa(int(fPriV) >> 3)} { for fmtK, fmtValidFunc := range fmtValidFuncs { fmtKT := "empty" if fmtK != "" { diff --git a/cmd/nerdctl/container_run_mount_linux_test.go b/cmd/nerdctl/container/container_run_mount_linux_test.go similarity index 82% rename from cmd/nerdctl/container_run_mount_linux_test.go rename to cmd/nerdctl/container/container_run_mount_linux_test.go index 352080de0be..1b3f651cef2 100644 --- a/cmd/nerdctl/container_run_mount_linux_test.go +++ b/cmd/nerdctl/container/container_run_mount_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -23,11 +23,15 @@ import ( "strings" "testing" - "github.com/containerd/containerd/mount" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" mobymount "github.com/moby/sys/mount" "gotest.tools/v3/assert" + + "github.com/containerd/containerd/v2/core/mount" + + "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" ) func TestRunVolume(t *testing.T) { @@ -85,8 +89,23 @@ func TestRunVolume(t *testing.T) { func TestRunAnonymousVolume(t *testing.T) { t.Parallel() base := testutil.NewBase(t) - base.Cmd("run", "--rm", "-v", "/foo", testutil.AlpineImage, - "mountpoint", "-q", "/foo").AssertOK() + base.Cmd("run", "--rm", "-v", "/foo", testutil.AlpineImage).AssertOK() + base.Cmd("run", "--rm", "-v", "TestVolume2:/foo", testutil.AlpineImage).AssertOK() + base.Cmd("run", "--rm", "-v", "TestVolume", testutil.AlpineImage).AssertOK() + + // Destination must be an absolute path not named volume + base.Cmd("run", "--rm", "-v", "TestVolume2:TestVolumes", testutil.AlpineImage).AssertFail() +} + +func TestRunVolumeRelativePath(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + base.Dir = t.TempDir() + base.Cmd("run", "--rm", "-v", "./foo:/mnt/foo", testutil.AlpineImage).AssertOK() + base.Cmd("run", "--rm", "-v", "./foo", testutil.AlpineImage).AssertOK() + + // Destination must be an absolute path not a relative path + base.Cmd("run", "--rm", "-v", "./foo:./foo", testutil.AlpineImage).AssertFail() } func TestRunAnonymousVolumeWithTypeMountFlag(t *testing.T) { @@ -99,8 +118,8 @@ func TestRunAnonymousVolumeWithTypeMountFlag(t *testing.T) { func TestRunAnonymousVolumeWithBuild(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() imageName := testutil.Identifier(t) defer base.Cmd("rmi", imageName).Run() @@ -108,9 +127,7 @@ func TestRunAnonymousVolumeWithBuild(t *testing.T) { VOLUME /foo `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() base.Cmd("run", "--rm", "-v", "/foo", testutil.AlpineImage, @@ -120,8 +137,8 @@ VOLUME /foo func TestRunCopyingUpInitialContentsOnVolume(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() imageName := testutil.Identifier(t) defer base.Cmd("rmi", imageName).Run() volName := testutil.Identifier(t) + "-vol" @@ -132,9 +149,7 @@ RUN mkdir -p /mnt && echo hi > /mnt/initial_file CMD ["cat", "/mnt/initial_file"] `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() @@ -149,8 +164,8 @@ CMD ["cat", "/mnt/initial_file"] func TestRunCopyingUpInitialContentsOnDockerfileVolume(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() imageName := testutil.Identifier(t) defer base.Cmd("rmi", imageName).Run() volName := testutil.Identifier(t) + "-vol" @@ -162,9 +177,7 @@ VOLUME /mnt CMD ["cat", "/mnt/initial_file"] `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() //AnonymousVolume @@ -185,8 +198,8 @@ CMD ["cat", "/mnt/initial_file"] func TestRunCopyingUpInitialContentsOnVolumeShouldRetainSymlink(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() imageName := testutil.Identifier(t) defer base.Cmd("rmi", imageName).Run() @@ -197,9 +210,7 @@ CMD ["readlink", "/mnt/passwd"] `, testutil.AlpineImage) const expected = "../../../../../../../../../../../../../../../../../../etc/passwd\n" - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() @@ -210,8 +221,8 @@ CMD ["readlink", "/mnt/passwd"] func TestRunCopyingUpInitialContentsShouldNotResetTheCopiedContents(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() tID := testutil.Identifier(t) imageName := tID + "-img" volumeName := tID + "-vol" @@ -226,15 +237,13 @@ func TestRunCopyingUpInitialContentsShouldNotResetTheCopiedContents(t *testing.T RUN echo -n "rev0" > /mnt/file `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() base.Cmd("volume", "create", volumeName) runContainer := func() { - base.Cmd("run", "-d", "--name", containerName, "-v", volumeName+":/mnt", imageName, "sleep", "infinity").AssertOK() + base.Cmd("run", "-d", "--name", containerName, "-v", volumeName+":/mnt", imageName, "sleep", nerdtest.Infinity).AssertOK() } runContainer() base.EnsureContainerStarted(containerName) @@ -631,3 +640,82 @@ func isRootfsShareableMount() bool { return false } + +func TestRunVolumesFrom(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + tID := testutil.Identifier(t) + rwDir, err := os.MkdirTemp(t.TempDir(), "rw") + if err != nil { + t.Fatal(err) + } + roDir, err := os.MkdirTemp(t.TempDir(), "ro") + if err != nil { + t.Fatal(err) + } + rwVolName := tID + "-rw" + roVolName := tID + "-ro" + for _, v := range []string{rwVolName, roVolName} { + defer base.Cmd("volume", "rm", "-f", v).Run() + base.Cmd("volume", "create", v).AssertOK() + } + + fromContainerName := tID + "-from" + toContainerName := tID + "-to" + defer base.Cmd("rm", "-f", fromContainerName).AssertOK() + defer base.Cmd("rm", "-f", toContainerName).AssertOK() + base.Cmd("run", + "-d", + "--name", fromContainerName, + "-v", fmt.Sprintf("%s:/mnt1", rwDir), + "-v", fmt.Sprintf("%s:/mnt2:ro", roDir), + "-v", fmt.Sprintf("%s:/mnt3", rwVolName), + "-v", fmt.Sprintf("%s:/mnt4:ro", roVolName), + testutil.AlpineImage, + "top", + ).AssertOK() + base.Cmd("run", + "-d", + "--name", toContainerName, + "--volumes-from", fromContainerName, + testutil.AlpineImage, + "top", + ).AssertOK() + base.Cmd("exec", toContainerName, "sh", "-exc", "echo -n str1 > /mnt1/file1").AssertOK() + base.Cmd("exec", toContainerName, "sh", "-exc", "echo -n str2 > /mnt2/file2").AssertFail() + base.Cmd("exec", toContainerName, "sh", "-exc", "echo -n str3 > /mnt3/file3").AssertOK() + base.Cmd("exec", toContainerName, "sh", "-exc", "echo -n str4 > /mnt4/file4").AssertFail() + base.Cmd("rm", "-f", toContainerName).AssertOK() + base.Cmd("run", + "--rm", + "--volumes-from", fromContainerName, + testutil.AlpineImage, + "cat", "/mnt1/file1", "/mnt3/file3", + ).AssertOutExactly("str1str3") +} + +func TestBindMountWhenHostFolderDoesNotExist(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + "-host-dir-not-found" + hostDir, err := os.MkdirTemp(t.TempDir(), "rw") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(hostDir) + hp := filepath.Join(hostDir, "does-not-exist") + base.Cmd("run", "--name", containerName, "-d", "-v", fmt.Sprintf("%s:/tmp", + hp), testutil.AlpineImage).AssertOK() + base.Cmd("rm", "-f", containerName).AssertOK() + + // Host directory should get created + _, err = os.Stat(hp) + assert.NilError(t, err) + + // Test for --mount + os.RemoveAll(hp) + base.Cmd("run", "--name", containerName, "-d", "--mount", fmt.Sprintf("type=bind, source=%s, target=/tmp", + hp), testutil.AlpineImage).AssertFail() + _, err = os.Stat(hp) + assert.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/cmd/nerdctl/container/container_run_mount_windows_test.go b/cmd/nerdctl/container/container_run_mount_windows_test.go new file mode 100644 index 00000000000..b0e6afde18a --- /dev/null +++ b/cmd/nerdctl/container/container_run_mount_windows_test.go @@ -0,0 +1,215 @@ +/* + 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" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestRunMountVolume(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + tID := testutil.Identifier(t) + rwDir, err := os.MkdirTemp(t.TempDir(), "rw") + if err != nil { + t.Fatal(err) + } + roDir, err := os.MkdirTemp(t.TempDir(), "ro") + if err != nil { + t.Fatal(err) + } + rwVolName := tID + "-rw" + roVolName := tID + "-ro" + for _, v := range []string{rwVolName, roVolName} { + defer base.Cmd("volume", "rm", "-f", v).Run() + base.Cmd("volume", "create", v).AssertOK() + } + + containerName := tID + defer base.Cmd("rm", "-f", containerName).AssertOK() + base.Cmd("run", + "-d", + "--name", containerName, + "-v", fmt.Sprintf("%s:C:/mnt1", rwDir), + "-v", fmt.Sprintf("%s:C:/mnt2:ro", roDir), + "-v", fmt.Sprintf("%s:C:/mnt3", rwVolName), + "-v", fmt.Sprintf("%s:C:/mnt4:ro", roVolName), + testutil.CommonImage, + "ping localhost -t", + ).AssertOK() + + base.Cmd("exec", containerName, "cmd", "/c", "echo -n str1 > C:/mnt1/file1").AssertOK() + base.Cmd("exec", containerName, "cmd", "/c", "echo -n str2 > C:/mnt2/file2").AssertFail() + base.Cmd("exec", containerName, "cmd", "/c", "echo -n str3 > C:/mnt3/file3").AssertOK() + base.Cmd("exec", containerName, "cmd", "/c", "echo -n str4 > C:/mnt4/file4").AssertFail() + base.Cmd("rm", "-f", containerName).AssertOK() + + base.Cmd("run", + "--rm", + "-v", fmt.Sprintf("%s:C:/mnt1", rwDir), + "-v", fmt.Sprintf("%s:C:/mnt3", rwVolName), + testutil.CommonImage, + "cat", "C:/mnt1/file1", "C:/mnt3/file3", + ).AssertOutContainsAll("str1", "str3") + base.Cmd("run", + "--rm", + "-v", fmt.Sprintf("%s:C:/mnt3/mnt1", rwDir), + "-v", fmt.Sprintf("%s:C:/mnt3", rwVolName), + testutil.CommonImage, + "cat", "C:/mnt3/mnt1/file1", "C:/mnt3/file3", + ).AssertOutContainsAll("str1", "str3") +} + +func TestRunMountVolumeInspect(t *testing.T) { + base := testutil.NewBase(t) + testContainer := testutil.Identifier(t) + testVolume := testutil.Identifier(t) + + defer base.Cmd("volume", "rm", "-f", testVolume).Run() + base.Cmd("volume", "create", testVolume).AssertOK() + inspectVolume := base.InspectVolume(testVolume) + namedVolumeSource := inspectVolume.Mountpoint + + base.Cmd( + "run", "-d", "--name", testContainer, + "-v", "C:/mnt1", + "-v", "C:/mnt2:C:/mnt2", + "-v", "\\\\.\\pipe\\containerd-containerd:\\\\.\\pipe\\containerd-containerd", + "-v", fmt.Sprintf("%s:C:/mnt3", testVolume), + testutil.CommonImage, + ).AssertOK() + + inspect := base.InspectContainer(testContainer) + // convert array to map to get by key of Destination + actual := make(map[string]dockercompat.MountPoint) + for i := range inspect.Mounts { + actual[inspect.Mounts[i].Destination] = inspect.Mounts[i] + } + + expected := []struct { + dest string + mountPoint dockercompat.MountPoint + }{ + // anonymous volume + { + dest: "C:\\mnt1", + mountPoint: dockercompat.MountPoint{ + Type: "volume", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: "C:\\mnt1", + }, + }, + + // bind + { + dest: "C:\\mnt2", + mountPoint: dockercompat.MountPoint{ + Type: "bind", + Source: "C:\\mnt2", + Destination: "C:\\mnt2", + }, + }, + + // named pipe + { + dest: "\\\\.\\pipe\\containerd-containerd", + mountPoint: dockercompat.MountPoint{ + Type: "npipe", + Source: "\\\\.\\pipe\\containerd-containerd", + Destination: "\\\\.\\pipe\\containerd-containerd", + }, + }, + + // named volume + { + dest: "C:\\mnt3", + mountPoint: dockercompat.MountPoint{ + Type: "volume", + Name: testVolume, + Source: namedVolumeSource, + Destination: "C:\\mnt3", + }, + }, + } + + for i := range expected { + testCase := expected[i] + t.Logf("test volume[dest=%q]", testCase.dest) + + mountPoint, ok := actual[testCase.dest] + assert.Assert(base.T, ok) + + assert.Equal(base.T, testCase.mountPoint.Type, mountPoint.Type) + assert.Equal(base.T, testCase.mountPoint.Destination, mountPoint.Destination) + + if testCase.mountPoint.Source == "" { + // for anonymous volumes, we want to make sure that the source is not the same as the destination + assert.Assert(base.T, mountPoint.Source != testCase.mountPoint.Destination) + } else { + assert.Equal(base.T, testCase.mountPoint.Source, mountPoint.Source) + } + + if testCase.mountPoint.Name != "" { + assert.Equal(base.T, testCase.mountPoint.Name, mountPoint.Name) + } + } +} + +func TestRunMountAnonymousVolume(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + base.Cmd("run", "--rm", "-v", "TestVolume:C:/mnt", testutil.CommonImage).AssertOK() + + // For docker-campatibility, Unrecognised volume spec: invalid volume specification: 'TestVolume' + base.Cmd("run", "--rm", "-v", "TestVolume", testutil.CommonImage).AssertFail() + + // Destination must be an absolute path not named volume + base.Cmd("run", "--rm", "-v", "TestVolume2:TestVolumes", testutil.CommonImage).AssertFail() +} + +func TestRunMountRelativePath(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + base.Cmd("run", "--rm", "-v", "./mnt:C:/mnt1", testutil.CommonImage, "cmd").AssertOK() + + // Destination cannot be a relative path + base.Cmd("run", "--rm", "-v", "./mnt", testutil.CommonImage).AssertFail() + base.Cmd("run", "--rm", "-v", "./mnt:./mnt1", testutil.CommonImage, "cmd").AssertFail() +} + +func TestRunMountNamedPipeVolume(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + base.Cmd("run", "--rm", "-v", `\\.\pipe\containerd-containerd`, testutil.CommonImage).AssertFail() +} + +func TestRunMountVolumeSpec(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + base.Cmd("run", "--rm", "-v", `InvalidPathC:\TestVolume:C:\Mount`, testutil.CommonImage).AssertFail() + base.Cmd("run", "--rm", "-v", `C:\TestVolume:C:\Mount:ro,rw:boot`, testutil.CommonImage).AssertFail() + + // If -v is an empty string, it will be ignored + base.Cmd("run", "--rm", "-v", "", testutil.CommonImage).AssertOK() +} diff --git a/cmd/nerdctl/container_run_network.go b/cmd/nerdctl/container/container_run_network.go similarity index 90% rename from cmd/nerdctl/container_run_network.go rename to cmd/nerdctl/container/container_run_network.go index 8fc178c4bf7..fdf75a2e8ba 100644 --- a/cmd/nerdctl/container_run_network.go +++ b/cmd/nerdctl/container/container_run_network.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package container import ( "net" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/portutil" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/spf13/cobra" + + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/portutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func loadNetworkFlags(cmd *cobra.Command) (types.NetworkOptions, error) { @@ -77,6 +79,13 @@ func loadNetworkFlags(cmd *cobra.Command) (types.NetworkOptions, error) { } netOpts.IPAddress = ipAddress + // --ip6= + ip6Address, err := cmd.Flags().GetString("ip6") + if err != nil { + return netOpts, err + } + netOpts.IP6Address = ip6Address + // -h/--hostname= hostName, err := cmd.Flags().GetString("hostname") if err != nil { @@ -135,7 +144,7 @@ func loadNetworkFlags(cmd *cobra.Command) (types.NetworkOptions, error) { return netOpts, err } portSlice = strutil.DedupeStrSlice(portSlice) - portMappings := []gocni.PortMapping{} + portMappings := []cni.PortMapping{} for _, p := range portSlice { pm, err := portutil.ParseFlagP(p) if err != nil { diff --git a/cmd/nerdctl/container_run_network_base_test.go b/cmd/nerdctl/container/container_run_network_base_test.go similarity index 85% rename from cmd/nerdctl/container_run_network_base_test.go rename to cmd/nerdctl/container/container_run_network_base_test.go index d1cbfccc149..60a27be5202 100644 --- a/cmd/nerdctl/container_run_network_base_test.go +++ b/cmd/nerdctl/container/container_run_network_base_test.go @@ -16,19 +16,19 @@ limitations under the License. */ -package main +package container import ( "fmt" "io" "net" - "regexp" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" ) // Tests various port mapping argument combinations by starting an nginx container and @@ -222,28 +222,3 @@ func baseTestRunPort(t *testing.T, nginxImage string, nginxIndexHTMLSnippet stri } } - -func valuesOfMapStringString(m map[string]string) map[string]struct{} { - res := make(map[string]struct{}) - for _, v := range m { - res[v] = struct{}{} - } - return res -} - -func extractHostPort(portMapping string, port string) (string, error) { - // Regular expression to extract host port from port mapping information - re := regexp.MustCompile(`(?P\d{1,5})/tcp ->.*?0.0.0.0:(?P\d{1,5}).*?`) - portMappingLines := strings.Split(portMapping, "\n") - for _, portMappingLine := range portMappingLines { - // Find the matches - matches := re.FindStringSubmatch(portMappingLine) - // Check if there is a match - if len(matches) >= 3 && matches[1] == port { - // Extract the host port number - hostPort := matches[2] - return hostPort, nil - } - } - return "", fmt.Errorf("could not extract host port from port mapping: %s", portMapping) -} diff --git a/cmd/nerdctl/container_run_network_linux_test.go b/cmd/nerdctl/container/container_run_network_linux_test.go similarity index 54% rename from cmd/nerdctl/container_run_network_linux_test.go rename to cmd/nerdctl/container/container_run_network_linux_test.go index 693f3d1db58..8d020905eb8 100644 --- a/cmd/nerdctl/container_run_network_linux_test.go +++ b/cmd/nerdctl/container/container_run_network_linux_test.go @@ -14,24 +14,64 @@ limitations under the License. */ -package main +package container import ( "fmt" "io" + "net" + "os" + "os/exec" + "path/filepath" "regexp" "runtime" "strings" "testing" + "time" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/opencontainers/go-digest" + "github.com/vishvananda/netlink" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" + + "github.com/containerd/containerd/v2/defaults" + "github.com/containerd/containerd/v2/pkg/netns" + "github.com/containerd/errdefs" + + "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" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" ) +func extractHostPort(portMapping string, port string) (string, error) { + // Regular expression to extract host port from port mapping information + re := regexp.MustCompile(`(?P\d{1,5})/tcp ->.*?0.0.0.0:(?P\d{1,5}).*?`) + portMappingLines := strings.Split(portMapping, "\n") + for _, portMappingLine := range portMappingLines { + // Find the matches + matches := re.FindStringSubmatch(portMappingLine) + // Check if there is a match + if len(matches) >= 3 && matches[1] == port { + // Extract the host port number + hostPort := matches[2] + return hostPort, nil + } + } + return "", fmt.Errorf("could not extract host port from port mapping: %s", portMapping) +} + +func valuesOfMapStringString(m map[string]string) map[string]struct{} { + res := make(map[string]struct{}) + for _, v := range m { + res[v] = struct{}{} + } + return res +} + // TestRunInternetConnectivity tests Internet connectivity with `apk update` func TestRunInternetConnectivity(t *testing.T) { base := testutil.NewBase(t) @@ -42,6 +82,7 @@ func TestRunInternetConnectivity(t *testing.T) { type testCase struct { args []string } + customNetID := base.InspectNetwork(customNet).ID testCases := []testCase{ { args: []string{"--net", "bridge"}, @@ -49,6 +90,12 @@ func TestRunInternetConnectivity(t *testing.T) { { args: []string{"--net", customNet}, }, + { + args: []string{"--net", customNetID}, + }, + { + args: []string{"--net", customNetID[:12]}, + }, { args: []string{"--net", "host"}, }, @@ -307,15 +354,50 @@ func TestRunPort(t *testing.T) { } func TestRunWithInvalidPortThenCleanUp(t *testing.T) { + testCase := nerdtest.Setup() // docker does not set label restriction to 4096 bytes - testutil.DockerIncompatible(t) - t.Parallel() - base := testutil.NewBase(t) - containerName := testutil.Identifier(t) - defer base.Cmd("rm", "-f", containerName).Run() - base.Cmd("run", "--rm", "--name", containerName, "-p", "22200-22299:22200-22299", testutil.CommonImage).AssertFail() - base.Cmd("run", "--rm", "--name", containerName, "-p", "22200-22299:22200-22299", testutil.CommonImage).AssertCombinedOutContains(errdefs.ErrInvalidArgument.Error()) - base.Cmd("run", "--rm", "--name", containerName, testutil.CommonImage).AssertOK() + testCase.Require = test.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.TempDir(), "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--data-root", data.TempDir(), "--rm", "--name", data.Identifier(), "-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) { + getAddrHash := func(addr string) string { + const addrHashLen = 8 + + d := digest.SHA256.FromString(addr) + h := d.Encoded()[0:addrHashLen] + + return h + } + + dataRoot := data.TempDir() + h := getAddrHash(defaults.DefaultAddress) + dataStore := filepath.Join(dataRoot, h) + namespace := string(helpers.Read(nerdtest.Namespace)) + etchostsPath := filepath.Join(dataStore, "etchosts", namespace) + + etchostsDirs, err := os.ReadDir(etchostsPath) + + assert.NilError(t, err) + assert.Equal(t, len(etchostsDirs), 0) + }, + } + }, + }, + } + + testCase.Run(t) } func TestRunContainerWithStaticIP(t *testing.T) { @@ -346,12 +428,17 @@ func TestRunContainerWithStaticIP(t *testing.T) { useNetwork: true, checkTheIPAddress: false, }, - { - ip: "10.4.0.2", - shouldSuccess: true, - useNetwork: false, - checkTheIPAddress: false, - }, + // XXX see https://github.com/containerd/nerdctl/issues/3101 + // docker 24 silently ignored the ip - now, docker 26 is erroring out - furthermore, this ip only makes sense + // in the context of nerdctl bridge network, so, this test needs rewritting either way + /* + { + ip: "10.4.0.2", + shouldSuccess: true, + useNetwork: false, + checkTheIPAddress: false, + }, + */ } tID := testutil.Identifier(t) for i, tc := range testCases { @@ -408,6 +495,24 @@ func TestRunDNS(t *testing.T) { cmd.AssertOutContains("options attempts:10\n") } +func TestRunNetworkHostHostname(t *testing.T) { + base := testutil.NewBase(t) + + hostname, err := os.Hostname() + assert.NilError(t, err) + hostname = hostname + "\n" + base.Cmd("run", "--rm", "--network", "host", testutil.CommonImage, "hostname").AssertOutExactly(hostname) + base.Cmd("run", "--rm", "--network", "host", testutil.CommonImage, "sh", "-euxc", "echo $HOSTNAME").AssertOutExactly(hostname) + base.Cmd("run", "--rm", "--network", "host", "--hostname", "override", testutil.CommonImage, "hostname").AssertOutExactly("override\n") + base.Cmd("run", "--rm", "--network", "host", "--hostname", "override", testutil.CommonImage, "sh", "-euxc", "echo $HOSTNAME").AssertOutExactly("override\n") +} + +func TestRunNetworkHost2613(t *testing.T) { + base := testutil.NewBase(t) + + base.Cmd("run", "--rm", "--add-host", "foo:1.2.3.4", testutil.CommonImage, "getent", "hosts", "foo").AssertOutExactly("1.2.3.4 foo foo\n") +} + func TestSharedNetworkStack(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("--network=container: only supports linux now") @@ -427,7 +532,7 @@ func TestSharedNetworkStack(t *testing.T) { "--name", containerNameJoin, "--network=container:"+containerName, testutil.CommonImage, - "sleep", "infinity").AssertOK() + "sleep", nerdtest.Infinity).AssertOK() base.Cmd("exec", containerNameJoin, "wget", "-qO-", "http://127.0.0.1:80"). AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) @@ -439,48 +544,211 @@ func TestSharedNetworkStack(t *testing.T) { AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) } +func TestRunContainerInExistingNetNS(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("Can't create new netns in rootless mode") + } + testutil.DockerIncompatible(t) + base := testutil.NewBase(t) + + netNS, err := netns.NewNetNS(t.TempDir() + "/netns") + assert.NilError(t, err) + err = netNS.Do(func(netns ns.NetNS) error { + loopback, err := netlink.LinkByName("lo") + assert.NilError(t, err) + err = netlink.LinkSetUp(loopback) + assert.NilError(t, err) + return nil + }) + assert.NilError(t, err) + defer netNS.Remove() + + containerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", containerName).AssertOK() + base.Cmd("run", "-d", "--name", containerName, + "--network=ns:"+netNS.GetPath(), testutil.NginxAlpineImage).AssertOK() + base.EnsureContainerStarted(containerName) + time.Sleep(3 * time.Second) + + err = netNS.Do(func(netns ns.NetNS) error { + stdout, err := exec.Command("curl", "-s", "http://127.0.0.1:80").Output() + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(stdout), testutil.NginxAlpineIndexHTMLSnippet)) + return nil + }) + assert.NilError(t, err) +} + func TestRunContainerWithMACAddress(t *testing.T) { base := testutil.NewBase(t) tID := testutil.Identifier(t) networkBridge := "testNetworkBridge" + tID networkMACvlan := "testNetworkMACvlan" + tID networkIPvlan := "testNetworkIPvlan" + tID - base.Cmd("network", "create", networkBridge, "--driver", "bridge").AssertOK() - base.Cmd("network", "create", networkMACvlan, "--driver", "macvlan").AssertOK() - base.Cmd("network", "create", networkIPvlan, "--driver", "ipvlan").AssertOK() - t.Cleanup(func() { + tearDown := func() { base.Cmd("network", "rm", networkBridge).Run() base.Cmd("network", "rm", networkMACvlan).Run() base.Cmd("network", "rm", networkIPvlan).Run() - }) + } + + tearDown() + t.Cleanup(tearDown) + + base.Cmd("network", "create", networkBridge, "--driver", "bridge").AssertOK() + base.Cmd("network", "create", networkMACvlan, "--driver", "macvlan").AssertOK() + base.Cmd("network", "create", networkIPvlan, "--driver", "ipvlan").AssertOK() + + defaultMac := base.Cmd("run", "--rm", "-i", "--network", "host", testutil.CommonImage). + CmdOption(testutil.WithStdin(strings.NewReader("ip addr show eth0 | grep ether | awk '{printf $2}'"))). + Run().Stdout() + + passedMac := "we expect the generated mac on the output" + tests := []struct { Network string WantErr bool Expect string }{ - {"host", true, "conflicting options"}, - {"none", true, "can't open '/sys/class/net/eth0/address'"}, - {"container:whatever" + tID, true, "conflicting options"}, - {"bridge", false, ""}, - {networkBridge, false, ""}, - {networkMACvlan, false, ""}, + {"host", false, defaultMac}, // anything but the actual address being passed + {"none", false, ""}, // nothing + {"container:whatever" + tID, true, "container"}, // "No such container" vs. "could not find container" + {"bridge", false, passedMac}, + {networkBridge, false, passedMac}, + {networkMACvlan, false, passedMac}, {networkIPvlan, true, "not support"}, } - for _, test := range tests { - macAddress, err := nettestutil.GenerateMACAddress() - if err != nil { - t.Errorf("failed to generate MAC address: %s", err) - } - if test.Expect == "" && !test.WantErr { - test.Expect = macAddress - } - cmd := base.Cmd("run", "--rm", "--network", test.Network, "--mac-address", macAddress, testutil.CommonImage, "cat", "/sys/class/net/eth0/address") - if test.WantErr { - cmd.AssertFail() - cmd.AssertCombinedOutContains(test.Expect) - } else { - cmd.AssertOK() - cmd.AssertOutContains(test.Expect) + + for i, test := range tests { + containerName := fmt.Sprintf("%s_%d", tID, i) + testName := fmt.Sprintf("%s_container:%s_network:%s_expect:%s", tID, containerName, test.Network, test.Expect) + expect := test.Expect + network := test.Network + wantErr := test.WantErr + t.Run(testName, func(tt *testing.T) { + tt.Parallel() + + macAddress, err := nettestutil.GenerateMACAddress() + if err != nil { + t.Errorf("failed to generate MAC address: %s", err) + } + if expect == passedMac { + expect = macAddress + } + + res := base.Cmd("run", "--rm", "-i", "--network", network, "--mac-address", macAddress, testutil.CommonImage). + CmdOption(testutil.WithStdin(strings.NewReader("ip addr show eth0 | grep ether | awk '{printf $2}'"))).Run() + + if wantErr { + assert.Assert(t, res.ExitCode != 0, "Command should have failed", res) + assert.Assert(t, strings.Contains(res.Combined(), expect), fmt.Sprintf("expected output to contain %q: %q", expect, res.Combined())) + } else { + assert.Assert(t, res.ExitCode == 0, "Command should have succeeded", res) + assert.Assert(t, strings.Contains(res.Stdout(), expect), fmt.Sprintf("expected output to contain %q: %q", expect, res.Stdout())) + } + }) + + } +} + +func TestHostsFileMounts(t *testing.T) { + if rootlessutil.IsRootless() { + if detachedNetNS, _ := rootlessutil.DetachedNetNS(); detachedNetNS != "" { + t.Skip("/etc/hosts is not writable") } } + base := testutil.NewBase(t) + + base.Cmd("run", "--rm", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/hosts").AssertOK() + base.Cmd("run", "--rm", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/hosts").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/hosts:/etc/hosts:ro", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/hosts").AssertFail() + // add a line into /etc/hosts and remove it. + base.Cmd("run", "--rm", "-v", "/etc/hosts:/etc/hosts", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/hosts").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/hosts:/etc/hosts", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "head -n -1 /etc/hosts > temp && cat temp > /etc/hosts").AssertOK() + + base.Cmd("run", "--rm", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() + base.Cmd("run", "--rm", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/resolv.conf:/etc/resolv.conf:ro", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertFail() + // add a line into /etc/resolv.conf and remove it. + base.Cmd("run", "--rm", "-v", "/etc/resolv.conf:/etc/resolv.conf", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/resolv.conf:/etc/resolv.conf", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "head -n -1 /etc/resolv.conf > temp && cat temp > /etc/resolv.conf").AssertOK() +} + +func TestRunContainerWithStaticIP6(t *testing.T) { + if rootlessutil.IsRootless() { + t.Skip("Static IP6 assignment is not supported rootless mode yet.") + } + networkName := "test-network" + networkSubnet := "2001:db8:5::/64" + _, subnet, err := net.ParseCIDR(networkSubnet) + assert.Assert(t, err == nil) + base := testutil.NewBaseWithIPv6Compatible(t) + base.Cmd("network", "create", networkName, "--subnet", networkSubnet, "--ipv6").AssertOK() + t.Cleanup(func() { + base.Cmd("network", "rm", networkName).Run() + }) + testCases := []struct { + ip string + shouldSuccess bool + checkTheIPAddress bool + }{ + { + ip: "", + shouldSuccess: true, + checkTheIPAddress: false, + }, + { + ip: "2001:db8:5::6", + shouldSuccess: true, + checkTheIPAddress: true, + }, + { + ip: "2001:db8:4::6", + shouldSuccess: false, + checkTheIPAddress: false, + }, + } + tID := testutil.Identifier(t) + for i, tc := range testCases { + i := i + tc := tc + tcName := fmt.Sprintf("%+v", tc) + t.Run(tcName, func(t *testing.T) { + testContainerName := fmt.Sprintf("%s-%d", tID, i) + base := testutil.NewBaseWithIPv6Compatible(t) + args := []string{ + "run", "--rm", "--name", testContainerName, "--network", networkName, + } + if tc.ip != "" { + args = append(args, "--ip6", tc.ip) + } + args = append(args, []string{testutil.NginxAlpineImage, "ip", "addr", "show", "dev", "eth0"}...) + cmd := base.Cmd(args...) + if !tc.shouldSuccess { + cmd.AssertFail() + return + } + cmd.AssertOutWithFunc(func(stdout string) error { + ip := helpers.FindIPv6(stdout) + if !subnet.Contains(ip) { + return fmt.Errorf("expected subnet %s include ip %s", subnet, ip) + } + if tc.checkTheIPAddress { + if ip.String() != tc.ip { + return fmt.Errorf("expected ip %s, got %s", tc.ip, ip) + } + } + return nil + }) + }) + } } diff --git a/cmd/nerdctl/container_run_network_windows_test.go b/cmd/nerdctl/container/container_run_network_windows_test.go similarity index 87% rename from cmd/nerdctl/container_run_network_windows_test.go rename to cmd/nerdctl/container/container_run_network_windows_test.go index 3ae5bcf2db3..53e9dd73bfc 100644 --- a/cmd/nerdctl/container_run_network_windows_test.go +++ b/cmd/nerdctl/container/container_run_network_windows_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -23,10 +23,11 @@ import ( "testing" "github.com/Microsoft/hcsshim" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) // TestRunInternetConnectivity tests Internet connectivity by pinging github.com. @@ -51,9 +52,10 @@ func TestRunInternetConnectivity(t *testing.T) { args := []string{"run", "--rm"} args = append(args, tc.args...) // TODO(aznashwan): smarter way to ensure internet connectivity is working. - args = append(args, testutil.CommonImage, "ping github.com") + // ping doesn't seem to work on GitHub Actions ("Request timed out.") + args = append(args, testutil.CommonImage, "curl.exe -sSL https://github.com") cmd := base.Cmd(args...) - cmd.AssertOutContains("Reply from") + cmd.AssertOutContains("") }) } } @@ -124,22 +126,22 @@ func TestHnsEndpointsExistDuringContainerLifecycle(t *testing.T) { "tail", "-f", ) t.Logf("Creating HNS lifecycle test container with command: %q", strings.Join(cmd.Command, " ")) - containerId := strings.TrimSpace(cmd.Run().Stdout()) - t.Logf("HNS endpoint lifecycle test container ID: %q", containerId) + containerID := strings.TrimSpace(cmd.Run().Stdout()) + t.Logf("HNS endpoint lifecycle test container ID: %q", containerID) // HNS endpoints should be allocated on container creation. - assertHnsEndpointsExistence(t, true, containerId, testNet.Name) + assertHnsEndpointsExistence(t, true, containerID, testNet.Name) // Starting and stopping the container should NOT affect/change the endpoints. - base.Cmd("start", containerId).AssertOK() - assertHnsEndpointsExistence(t, true, containerId, testNet.Name) + base.Cmd("start", containerID).AssertOK() + assertHnsEndpointsExistence(t, true, containerID, testNet.Name) - base.Cmd("stop", containerId).AssertOK() - assertHnsEndpointsExistence(t, true, containerId, testNet.Name) + base.Cmd("stop", containerID).AssertOK() + assertHnsEndpointsExistence(t, true, containerID, testNet.Name) // Removing the container should remove the HNS endpoints. - base.Cmd("rm", containerId).AssertOK() - assertHnsEndpointsExistence(t, false, containerId, testNet.Name) + base.Cmd("rm", containerID).AssertOK() + assertHnsEndpointsExistence(t, false, containerID, testNet.Name) } // Returns a network to be used for testing. diff --git a/cmd/nerdctl/container_run_restart_linux_test.go b/cmd/nerdctl/container/container_run_restart_linux_test.go similarity index 96% rename from cmd/nerdctl/container_run_restart_linux_test.go rename to cmd/nerdctl/container/container_run_restart_linux_test.go index be570699ac9..c3411c6aaeb 100644 --- a/cmd/nerdctl/container_run_restart_linux_test.go +++ b/cmd/nerdctl/container/container_run_restart_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -24,11 +24,11 @@ import ( "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "gotest.tools/v3/assert" "gotest.tools/v3/poll" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" ) func TestRunRestart(t *testing.T) { @@ -41,7 +41,7 @@ func TestRunRestart(t *testing.T) { } base := testutil.NewBase(t) if !base.DaemonIsKillable { - t.Skip("daemon is not killable (hint: set \"-test.kill-daemon\")") + t.Skip("daemon is not killable (hint: set \"-test.allow-kill-daemon\")") } t.Log("NOTE: this test may take a while") diff --git a/cmd/nerdctl/container_run_runtime_linux_test.go b/cmd/nerdctl/container/container_run_runtime_linux_test.go similarity index 92% rename from cmd/nerdctl/container_run_runtime_linux_test.go rename to cmd/nerdctl/container/container_run_runtime_linux_test.go index fc33c21004a..ea7473f2d20 100644 --- a/cmd/nerdctl/container_run_runtime_linux_test.go +++ b/cmd/nerdctl/container/container_run_runtime_linux_test.go @@ -14,12 +14,12 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestRunSysctl(t *testing.T) { diff --git a/cmd/nerdctl/container_run_security_linux_test.go b/cmd/nerdctl/container/container_run_security_linux_test.go similarity index 75% rename from cmd/nerdctl/container_run_security_linux_test.go rename to cmd/nerdctl/container/container_run_security_linux_test.go index 49db43cbfe2..6a4cc35bb4b 100644 --- a/cmd/nerdctl/container_run_security_linux_test.go +++ b/cmd/nerdctl/container/container_run_security_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -24,11 +24,11 @@ import ( "strings" "testing" - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func getCapEff(base *testutil.Base, args ...string) uint64 { @@ -182,8 +182,8 @@ func TestRunApparmor(t *testing.T) { attrCurrentEnforceExpected := fmt.Sprintf("%s (enforce)\n", defaultProfile) base.Cmd("run", "--rm", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutExactly(attrCurrentEnforceExpected) base.Cmd("run", "--rm", "--security-opt", "apparmor="+defaultProfile, testutil.AlpineImage, "cat", attrCurrentPath).AssertOutExactly(attrCurrentEnforceExpected) - base.Cmd("run", "--rm", "--security-opt", "apparmor=unconfined", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutExactly("unconfined\n") - base.Cmd("run", "--rm", "--privileged", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutExactly("unconfined\n") + base.Cmd("run", "--rm", "--security-opt", "apparmor=unconfined", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutContains("unconfined") + base.Cmd("run", "--rm", "--privileged", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutContains("unconfined") } // TestRunSeccompCapSysPtrace tests https://github.com/containerd/nerdctl/issues/976 @@ -193,6 +193,61 @@ func TestRunSeccompCapSysPtrace(t *testing.T) { // Docker/Moby 's seccomp profile allows ptrace(2) by default, but containerd does not (yet): https://github.com/containerd/containerd/issues/6802 } +func TestRunSystemPathsUnconfined(t *testing.T) { + base := testutil.NewBase(t) + + const findmnt = "`apk add -q findmnt && findmnt -R /proc && findmnt -R /sys`" + result := base.Cmd("run", "--rm", testutil.AlpineImage, "sh", "-euxc", findmnt).Run() + defaultContainerOutput := result.Combined() + + var confined []string + + for _, path := range []string{ + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/sched_debug", + "/proc/scsi", + "/proc/timer_list", + "/proc/timer_stats", + "/sys/firmware", + "/sys/fs/selinux", + } { + // Not each distribution will support every masked path here. + if strings.Contains(defaultContainerOutput, path) { + confined = append(confined, path) + } + } + + assert.Check(t, len(confined) != 0, "Default container has no confined paths to validate") + + result = base.Cmd("run", "--rm", "--security-opt", "systempaths=unconfined", testutil.AlpineImage, "sh", "-euxc", findmnt).Run() + unconfinedContainerOutput := result.Combined() + + for _, path := range confined { + assert.Assert(t, !strings.Contains(unconfinedContainerOutput, path), fmt.Sprintf("%s should not be masked when unconfined", path)) + } + + for _, path := range []string{ + "/proc/acpi", + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sysrq-trigger", + "/proc/sys", + } { + findmntPath := fmt.Sprintf("`apk add -q findmnt && findmnt %s`", path) + + result := base.Cmd("run", "--rm", testutil.AlpineImage, "sh", "-euxc", findmntPath).Run() + + // Not each distribution will support every read-only path here. + if strings.Contains(result.Combined(), path) { + result = base.Cmd("run", "--rm", "--security-opt", "systempaths=unconfined", testutil.AlpineImage, "sh", "-euxc", findmntPath).Run() + assert.Assert(t, !strings.Contains(result.Combined(), "ro,"), fmt.Sprintf("%s should not be read-only when unconfined", path)) + } + } +} + func TestRunPrivileged(t *testing.T) { // docker does not support --privileged-without-host-devices testutil.DockerIncompatible(t) @@ -224,7 +279,7 @@ func TestRunPrivileged(t *testing.T) { res := base.Cmd("run", "--rm", "--privileged", "--security-opt", "privileged-without-host-devices", testutil.AlpineImage, "ls", devPath).Run() // normally for not a exists file, the `ls` will return `1``. - assert.Check(t, res.ExitCode != 0, res.Combined()) + assert.Check(t, res.ExitCode != 0, res) // something like `ls: /dev/dummy-zero: No such file or directory` assert.Check(t, strings.Contains(res.Combined(), "No such file or directory")) diff --git a/cmd/nerdctl/container/container_run_soci_linux_test.go b/cmd/nerdctl/container/container_run_soci_linux_test.go new file mode 100644 index 00000000000..57cf0599525 --- /dev/null +++ b/cmd/nerdctl/container/container_run_soci_linux_test.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 container + +import ( + "os/exec" + "strings" + "testing" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestRunSoci(t *testing.T) { + testutil.DockerIncompatible(t) + tests := []struct { + name string + image string + remoteSnapshotsExpectedCount int + }{ + { + name: "Run with SOCI", + image: testutil.FfmpegSociImage, + remoteSnapshotsExpectedCount: 11, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := testutil.NewBase(t) + helpers.RequiresSoci(base) + + //counting initial snapshot mounts + initialMounts, err := exec.Command("mount").Output() + if err != nil { + t.Fatal(err) + } + + remoteSnapshotsInitialCount := strings.Count(string(initialMounts), "fuse.rawBridge") + + runOutput := base.Cmd("--snapshotter=soci", "run", "--rm", testutil.FfmpegSociImage).Out() + base.T.Logf("run output: %s", runOutput) + + actualMounts, err := exec.Command("mount").Output() + if err != nil { + t.Fatal(err) + } + remoteSnapshotsActualCount := strings.Count(string(actualMounts), "fuse.rawBridge") + base.T.Logf("number of actual mounts: %v", remoteSnapshotsActualCount) + + rmiOutput := base.Cmd("rmi", testutil.FfmpegSociImage).Out() + base.T.Logf("rmi output: %s", rmiOutput) + + base.T.Logf("number of expected mounts: %v", tt.remoteSnapshotsExpectedCount) + + if tt.remoteSnapshotsExpectedCount != (remoteSnapshotsActualCount - remoteSnapshotsInitialCount) { + t.Fatalf("incorrect number of remote snapshots; expected=%d, actual=%d", + tt.remoteSnapshotsExpectedCount, remoteSnapshotsActualCount-remoteSnapshotsInitialCount) + } + }) + } +} diff --git a/cmd/nerdctl/container_run_stargz_linux_test.go b/cmd/nerdctl/container/container_run_stargz_linux_test.go similarity index 77% rename from cmd/nerdctl/container_run_stargz_linux_test.go rename to cmd/nerdctl/container/container_run_stargz_linux_test.go index 0e1c8266b3d..70e3b0c7d55 100644 --- a/cmd/nerdctl/container_run_stargz_linux_test.go +++ b/cmd/nerdctl/container/container_run_stargz_linux_test.go @@ -14,28 +14,24 @@ limitations under the License. */ -package main +package container import ( + "runtime" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestRunStargz(t *testing.T) { testutil.DockerIncompatible(t) + if runtime.GOARCH != "amd64" { + t.Skip("skipping test as FedoraESGZImage is amd64 only") + } + base := testutil.NewBase(t) - requiresStargz(base) + helpers.RequiresStargz(base) // if stargz snapshotter is functional, "/.stargz-snapshotter" appears base.Cmd("--snapshotter=stargz", "run", "--rm", testutil.FedoraESGZImage, "ls", "/.stargz-snapshotter").AssertOK() } - -func requiresStargz(base *testutil.Base) { - info := base.Info() - for _, p := range info.Plugins.Storage { - if p == "stargz" { - return - } - } - base.T.Skip("test requires stargz") -} diff --git a/cmd/nerdctl/container/container_run_systemd_linux_test.go b/cmd/nerdctl/container/container_run_systemd_linux_test.go new file mode 100644 index 00000000000..065e450873c --- /dev/null +++ b/cmd/nerdctl/container/container_run_systemd_linux_test.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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestRunWithSystemdAlways(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("container", "rm", "-f", containerName).AssertOK() + + base.Cmd("run", "--name", containerName, "--systemd=always", "--entrypoint=/bin/bash", testutil.UbuntuImage, "-c", "mount | grep cgroup").AssertOutContains("(rw,") + + base.Cmd("inspect", "--format", "{{json .Config.Labels}}", containerName).AssertOutContains("SIGRTMIN+3") + +} + +func TestRunWithSystemdTrueEnabled(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("container", "rm", "-f", containerName).AssertOK() + + base.Cmd("run", "-d", "--name", containerName, "--systemd=true", "--entrypoint=/sbin/init", testutil.SystemdImage).AssertOK() + + base.Cmd("inspect", "--format", "{{json .Config.Labels}}", containerName).AssertOutContains("SIGRTMIN+3") + + base.Cmd("exec", containerName, "sh", "-c", "--", `tries=0 +until systemctl is-system-running >/dev/null 2>&1; do + >&2 printf "Waiting for systemd to come up...\n" + sleep 1s + tries=$(( tries + 1)) + [ $tries -lt 10 ] || { + >&2 printf "systemd failed to come up in a reasonable amount of time\n" + exit 1 + } +done +systemctl list-jobs`).AssertOutContains("jobs") +} + +func TestRunWithSystemdTrueDisabled(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", containerName).AssertOK() + + base.Cmd("run", "--name", containerName, "--systemd=true", "--entrypoint=/bin/bash", testutil.SystemdImage, "-c", "systemctl list-jobs || true").AssertCombinedOutContains("System has not been booted with systemd as init system") +} + +func TestRunWithSystemdFalse(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", containerName).AssertOK() + + base.Cmd("run", "--name", containerName, "--systemd=false", "--entrypoint=/bin/bash", testutil.UbuntuImage, "-c", "mount | grep cgroup").AssertOutContains("(ro,") + + base.Cmd("inspect", "--format", "{{json .Config.Labels}}", containerName).AssertOutContains("SIGTERM") +} + +func TestRunWithNoSystemd(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", containerName).AssertOK() + + base.Cmd("run", "--name", containerName, "--entrypoint=/bin/bash", testutil.UbuntuImage, "-c", "mount | grep cgroup").AssertOutContains("(ro,") + + base.Cmd("inspect", "--format", "{{json .Config.Labels}}", containerName).AssertOutContains("SIGTERM") +} + +func TestRunWithSystemdPrivilegedError(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + + base.Cmd("run", "--privileged", "--rm", "--systemd=always", "--entrypoint=/sbin/init", testutil.SystemdImage).AssertCombinedOutContains("if --privileged is used with systemd `--security-opt privileged-without-host-devices` must also be used") +} + +func TestRunWithSystemdPrivilegedSuccess(t *testing.T) { + testutil.DockerIncompatible(t) + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + defer base.Cmd("container", "rm", "-f", containerName).AssertOK() + + base.Cmd("run", "-d", "--name", containerName, "--privileged", "--security-opt", "privileged-without-host-devices", "--systemd=true", "--entrypoint=/sbin/init", testutil.SystemdImage).AssertOK() + + base.Cmd("inspect", "--format", "{{json .Config.Labels}}", containerName).AssertOutContains("SIGRTMIN+3") +} diff --git a/cmd/nerdctl/container_run_test.go b/cmd/nerdctl/container/container_run_test.go similarity index 61% rename from cmd/nerdctl/container_run_test.go rename to cmd/nerdctl/container/container_run_test.go index 1bf584a2be3..9a22aded3b0 100644 --- a/cmd/nerdctl/container_run_test.go +++ b/cmd/nerdctl/container/container_run_test.go @@ -14,30 +14,36 @@ limitations under the License. */ -package main +package container import ( + "bufio" + "bytes" "errors" "fmt" "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "testing" "time" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" "gotest.tools/v3/poll" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func TestRunEntrypointWithBuild(t *testing.T) { t.Parallel() testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() imageName := testutil.Identifier(t) defer base.Cmd("rmi", imageName).Run() @@ -46,9 +52,7 @@ ENTRYPOINT ["echo", "foo"] CMD ["echo", "bar"] `, testutil.CommonImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, buildCtx).AssertOK() base.Cmd("run", "--rm", imageName).AssertOutExactly("foo echo bar\n") @@ -144,7 +148,7 @@ func TestRunCIDFile(t *testing.T) { func TestRunEnvFile(t *testing.T) { t.Parallel() base := testutil.NewBase(t) - base.Env = append(os.Environ(), "HOST_ENV=ENV-IN-HOST") + base.Env = append(base.Env, "HOST_ENV=ENV-IN-HOST") tID := testutil.Identifier(t) file1, err := os.CreateTemp("", tID) @@ -171,7 +175,7 @@ func TestRunEnvFile(t *testing.T) { func TestRunEnv(t *testing.T) { t.Parallel() base := testutil.NewBase(t) - base.Env = append(os.Environ(), "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") + base.Env = append(base.Env, "CORGE=corge-value-in-host", "GARPLY=garply-value-in-host") base.Cmd("run", "--rm", "--env", "FOO=foo1,foo2", "--env", "BAR=bar1 bar2", @@ -216,6 +220,19 @@ func TestRunEnv(t *testing.T) { return nil }) } +func TestRunHostnameEnv(t *testing.T) { + t.Parallel() + base := testutil.NewBase(t) + + base.Cmd("run", "-i", "--rm", testutil.CommonImage). + CmdOption(testutil.WithStdin(strings.NewReader(`[[ "HOSTNAME=$(hostname)" == "$(env | grep HOSTNAME)" ]]`))). + AssertOK() + + if runtime.GOOS == "windows" { + t.Skip("run --hostname not implemented on Windows yet") + } + base.Cmd("run", "--rm", "--hostname", "foobar", testutil.CommonImage, "env").AssertOutContains("HOSTNAME=foobar") +} func TestRunStdin(t *testing.T) { t.Parallel() @@ -312,19 +329,44 @@ func TestRunWithJournaldLogDriver(t *testing.T) { time.Sleep(3 * time.Second) journalctl, err := exec.LookPath("journalctl") assert.NilError(t, err) + inspectedContainer := base.InspectContainer(containerName) - found := 0 - check := func(log poll.LogT) poll.Result { - res := icmd.RunCmd(icmd.Command(journalctl, "--no-pager", "--since", "2 minutes ago", fmt.Sprintf("SYSLOG_IDENTIFIER=%s", inspectedContainer.ID[:12]))) - assert.Equal(t, 0, res.ExitCode, res.Combined()) - if strings.Contains(res.Stdout(), "bar") && strings.Contains(res.Stdout(), "foo") { - found = 1 - return poll.Success() - } - return poll.Continue("reading from journald is not yet finished") + + type testCase struct { + name string + filter string + } + testCases := []testCase{ + { + name: "filter journald logs using SYSLOG_IDENTIFIER field", + filter: fmt.Sprintf("SYSLOG_IDENTIFIER=%s", inspectedContainer.ID[:12]), + }, + { + name: "filter journald logs using CONTAINER_NAME field", + filter: fmt.Sprintf("CONTAINER_NAME=%s", containerName), + }, + { + name: "filter journald logs using IMAGE_NAME field", + filter: fmt.Sprintf("IMAGE_NAME=%s", testutil.CommonImage), + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + found := 0 + check := func(log poll.LogT) poll.Result { + res := icmd.RunCmd(icmd.Command(journalctl, "--no-pager", "--since", "2 minutes ago", tc.filter)) + assert.Equal(t, 0, res.ExitCode, res) + if strings.Contains(res.Stdout(), "bar") && strings.Contains(res.Stdout(), "foo") { + found = 1 + return poll.Success() + } + return poll.Continue("reading from journald is not yet finished") + } + poll.WaitOn(t, check, poll.WithDelay(100*time.Microsecond), poll.WithTimeout(20*time.Second)) + assert.Equal(t, 1, found) + }) } - poll.WaitOn(t, check, poll.WithDelay(100*time.Microsecond), poll.WithTimeout(20*time.Second)) - assert.Equal(t, 1, found) } func TestRunWithJournaldLogDriverAndLogOpt(t *testing.T) { @@ -345,7 +387,7 @@ func TestRunWithJournaldLogDriverAndLogOpt(t *testing.T) { found := 0 check := func(log poll.LogT) poll.Result { res := icmd.RunCmd(icmd.Command(journalctl, "--no-pager", "--since", "2 minutes ago", fmt.Sprintf("SYSLOG_IDENTIFIER=%s", inspectedContainer.ID))) - assert.Equal(t, 0, res.ExitCode, res.Combined()) + assert.Equal(t, 0, res.ExitCode, res) if strings.Contains(res.Stdout(), "bar") && strings.Contains(res.Stdout(), "foo") { found = 1 return poll.Success() @@ -357,6 +399,7 @@ func TestRunWithJournaldLogDriverAndLogOpt(t *testing.T) { } func TestRunWithLogBinary(t *testing.T) { + testutil.RequiresBuild(t) if runtime.GOOS == "windows" { t.Skip("buildkit is not enabled on windows, this feature may work on windows.") } @@ -366,8 +409,8 @@ func TestRunWithLogBinary(t *testing.T) { imageName := testutil.Identifier(t) + "-image" containerName := testutil.Identifier(t) - const dockerfile = ` -FROM golang:latest as builder + var dockerfile = ` +FROM ` + testutil.GolangImage + ` as builder WORKDIR /go/src/ RUN mkdir -p logger WORKDIR /go/src/logger @@ -383,7 +426,7 @@ RUN echo '\ "path/filepath" \n\ "sync" \n\ \n\ - "github.com/containerd/containerd/runtime/v2/logging"\n\ + "github.com/containerd/containerd/v2/core/runtime/v2/logging"\n\ )\n\ func main() {\n\ @@ -424,9 +467,7 @@ FROM scratch COPY --from=builder /go/src/logger/logger / ` - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) tmpDir := t.TempDir() base.Cmd("build", buildCtx, "--output", fmt.Sprintf("type=local,src=/go/src/logger/logger,dest=%s", tmpDir)).AssertOK() defer base.Cmd("image", "rm", "-f", imageName).AssertOK() @@ -444,26 +485,230 @@ COPY --from=builder /go/src/logger/logger / assert.Check(t, strings.Contains(log, "bar")) } -func TestRunWithTtyAndDetached(t *testing.T) { +// history: There was a bug that the --add-host items disappear when the another container created. +// This case ensures that it's doesn't happen. +// (https://github.com/containerd/nerdctl/issues/2560) +func TestRunAddHostRemainsWhenAnotherContainerCreated(t *testing.T) { if runtime.GOOS == "windows" { - t.Skip("json-file log driver is not yet implemented on Windows") + t.Skip("ocihook is not yet supported on Windows") } base := testutil.NewBase(t) - imageName := testutil.CommonImage - withoutTtyContainerName := "without-terminal-" + testutil.Identifier(t) - withTtyContainerName := "with-terminal-" + testutil.Identifier(t) - - // without -t, fail - base.Cmd("run", "-d", "--name", withoutTtyContainerName, imageName, "stty").AssertOK() - defer base.Cmd("container", "rm", "-f", withoutTtyContainerName).AssertOK() - base.Cmd("logs", withoutTtyContainerName).AssertCombinedOutContains("stty: standard input: Not a tty") - withoutTtyContainer := base.InspectContainer(withoutTtyContainerName) - assert.Equal(base.T, 1, withoutTtyContainer.State.ExitCode) - - // with -t, success - base.Cmd("run", "-d", "-t", "--name", withTtyContainerName, imageName, "stty").AssertOK() - defer base.Cmd("container", "rm", "-f", withTtyContainerName).AssertOK() - base.Cmd("logs", withTtyContainerName).AssertCombinedOutContains("speed 38400 baud; line = 0;") - withTtyContainer := base.InspectContainer(withTtyContainerName) - assert.Equal(base.T, 0, withTtyContainer.State.ExitCode) + + containerName := testutil.Identifier(t) + hostMapping := "test-add-host:10.0.0.1" + base.Cmd("run", "-d", "--add-host", hostMapping, "--name", containerName, testutil.CommonImage, "sleep", nerdtest.Infinity).AssertOK() + defer base.Cmd("container", "rm", "-f", containerName).Run() + + checkEtcHosts := func(stdout string) error { + matcher, err := regexp.Compile(`^10.0.0.1\s+test-add-host$`) + if err != nil { + return err + } + var found bool + sc := bufio.NewScanner(bytes.NewBufferString(stdout)) + for sc.Scan() { + if matcher.Match(sc.Bytes()) { + found = true + } + } + if !found { + return fmt.Errorf("host not found") + } + return nil + } + base.Cmd("exec", containerName, "cat", "/etc/hosts").AssertOutWithFunc(checkEtcHosts) + + // run another container + base.Cmd("run", "--rm", testutil.CommonImage).AssertOK() + + base.Cmd("exec", containerName, "cat", "/etc/hosts").AssertOutWithFunc(checkEtcHosts) +} + +// https://github.com/containerd/nerdctl/issues/2726 +func TestRunRmTime(t *testing.T) { + base := testutil.NewBase(t) + base.Cmd("pull", testutil.CommonImage) + t0 := time.Now() + base.Cmd("run", "--rm", testutil.CommonImage, "true").AssertOK() + t1 := time.Now() + took := t1.Sub(t0) + const deadline = 3 * time.Second + if took > deadline { + t.Fatalf("expected to have completed in %v, took %v", deadline, took) + } +} + +func runAttachStdin(t *testing.T, testStr string, args []string) string { + if runtime.GOOS == "windows" { + t.Skip("run attach test is not yet implemented on Windows") + } + + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + + opts := []func(*testutil.Cmd){ + testutil.WithStdin(strings.NewReader("echo " + testStr + "\nexit\n")), + } + + fullArgs := []string{"run", "--rm", "-i"} + fullArgs = append(fullArgs, args...) + fullArgs = append(fullArgs, + "--name", + containerName, + testutil.CommonImage, + ) + + defer base.Cmd("rm", "-f", containerName).AssertOK() + result := base.Cmd(fullArgs...).CmdOption(opts...).Run() + + return result.Combined() +} + +func runAttach(t *testing.T, testStr string, args []string) string { + if runtime.GOOS == "windows" { + t.Skip("run attach test is not yet implemented on Windows") + } + + t.Parallel() + base := testutil.NewBase(t) + containerName := testutil.Identifier(t) + + fullArgs := []string{"run"} + fullArgs = append(fullArgs, args...) + fullArgs = append(fullArgs, + "--name", + containerName, + testutil.CommonImage, + "sh", + "-euxc", + "echo "+testStr, + ) + + defer base.Cmd("rm", "-f", containerName).AssertOK() + result := base.Cmd(fullArgs...).Run() + + return result.Combined() +} + +func TestRunAttachFlag(t *testing.T) { + + type testCase struct { + name string + args []string + testFunc func(t *testing.T, testStr string, args []string) string + testStr string + expectedOut string + dockerOut string + } + testCases := []testCase{ + { + name: "AttachFlagStdin", + args: []string{"-a", "STDIN", "-a", "STDOUT"}, + testFunc: runAttachStdin, + testStr: "test-run-stdio", + expectedOut: "test-run-stdio", + dockerOut: "test-run-stdio", + }, + { + name: "AttachFlagStdOut", + args: []string{"-a", "STDOUT"}, + testFunc: runAttach, + testStr: "foo", + expectedOut: "foo", + dockerOut: "foo", + }, + { + name: "AttachFlagMixedValue", + args: []string{"-a", "STDIN", "-a", "invalid-value"}, + testFunc: runAttach, + testStr: "foo", + expectedOut: "invalid stream specified with -a flag. Valid streams are STDIN, STDOUT, and STDERR", + dockerOut: "valid streams are STDIN, STDOUT and STDERR", + }, + { + name: "AttachFlagInvalidValue", + args: []string{"-a", "invalid-stream"}, + testFunc: runAttach, + testStr: "foo", + expectedOut: "invalid stream specified with -a flag. Valid streams are STDIN, STDOUT, and STDERR", + dockerOut: "valid streams are STDIN, STDOUT and STDERR", + }, + { + name: "AttachFlagCaseInsensitive", + args: []string{"-a", "stdin", "-a", "stdout"}, + testFunc: runAttachStdin, + testStr: "test-run-stdio", + expectedOut: "test-run-stdio", + dockerOut: "test-run-stdio", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + actualOut := tc.testFunc(t, tc.testStr, tc.args) + errorMsg := fmt.Sprintf("%s failed;\nExpected: '%s'\nActual: '%s'", tc.name, tc.expectedOut, actualOut) + if testutil.GetTarget() == testutil.Docker { + assert.Equal(t, true, strings.Contains(actualOut, tc.dockerOut), errorMsg) + } else { + assert.Equal(t, true, strings.Contains(actualOut, tc.expectedOut), errorMsg) + } + }) + } +} + +func TestRunQuiet(t *testing.T) { + base := testutil.NewBase(t) + + teardown := func() { + base.Cmd("rmi", "-f", testutil.CommonImage).Run() + } + defer teardown() + teardown() + + sentinel := "test run quiet" + result := base.Cmd("run", "--rm", "--quiet", testutil.CommonImage, fmt.Sprintf(`echo "%s"`, sentinel)).Run() + assert.Assert(t, strings.Contains(result.Combined(), sentinel)) + + wasQuiet := func(output, sentinel string) bool { + return !strings.Contains(output, sentinel) + } + + // Docker and nerdctl image pulls are not 1:1. + if testutil.GetTarget() == testutil.Docker { + sentinel = "Pull complete" + } else { + sentinel = "resolved" + } + + assert.Assert(t, wasQuiet(result.Combined(), sentinel), "Found %s in container run output", sentinel) +} + +func TestRunFromOCIArchive(t *testing.T) { + testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + + // Docker does not support running container images from OCI archive. + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + imageName := testutil.Identifier(t) + + teardown := func() { + base.Cmd("rmi", "-f", imageName).Run() + } + defer teardown() + teardown() + + const sentinel = "test-nerdctl-run-from-oci-archive" + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "%s"]`, testutil.CommonImage, sentinel) + + buildCtx := helpers.CreateBuildContext(t, dockerfile) + tag := fmt.Sprintf("%s:latest", imageName) + 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) } diff --git a/cmd/nerdctl/container_run_user_linux_test.go b/cmd/nerdctl/container/container_run_user_linux_test.go similarity index 98% rename from cmd/nerdctl/container_run_user_linux_test.go rename to cmd/nerdctl/container/container_run_user_linux_test.go index 23cdee352c6..73d1753a867 100644 --- a/cmd/nerdctl/container_run_user_linux_test.go +++ b/cmd/nerdctl/container/container_run_user_linux_test.go @@ -14,12 +14,12 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestRunUserGID(t *testing.T) { diff --git a/cmd/nerdctl/container_run_user_windows_test.go b/cmd/nerdctl/container/container_run_user_windows_test.go similarity index 94% rename from cmd/nerdctl/container_run_user_windows_test.go rename to cmd/nerdctl/container/container_run_user_windows_test.go index 27cfd0697af..e92dc595598 100644 --- a/cmd/nerdctl/container_run_user_windows_test.go +++ b/cmd/nerdctl/container/container_run_user_windows_test.go @@ -14,12 +14,12 @@ limitations under the License. */ -package main +package container import ( "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestRunUserName(t *testing.T) { diff --git a/cmd/nerdctl/container_run_verify_linux_test.go b/cmd/nerdctl/container/container_run_verify_linux_test.go similarity index 62% rename from cmd/nerdctl/container_run_verify_linux_test.go rename to cmd/nerdctl/container/container_run_verify_linux_test.go index ad4bef7d802..7d12342cbb3 100644 --- a/cmd/nerdctl/container_run_verify_linux_test.go +++ b/cmd/nerdctl/container/container_run_verify_linux_test.go @@ -14,46 +14,44 @@ limitations under the License. */ -package main +package container import ( "fmt" - "os" "testing" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" ) func TestRunVerifyCosign(t *testing.T) { testutil.RequireExecutable(t, "cosign") testutil.DockerIncompatible(t) testutil.RequiresBuild(t) - t.Setenv("COSIGN_PASSWORD", "1") - keyPair := newCosignKeyPair(t, "cosign-key-pair") - defer keyPair.cleanup() + testutil.RegisterBuildCacheCleanup(t) + t.Parallel() + base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - localhostIP := "127.0.0.1" - t.Logf("localhost IP=%q", localhostIP) - testImageRef := fmt.Sprintf("%s:%d/%s", - localhostIP, reg.ListenPort, tID) - t.Logf("testImageRef=%q", testImageRef) + base.Env = append(base.Env, "COSIGN_PASSWORD=1") + keyPair := helpers.NewCosignKeyPair(t, "cosign-key-pair", "1") + reg := testregistry.NewWithNoAuth(base, 0, false) + t.Cleanup(func() { + keyPair.Cleanup() + reg.Cleanup(nil) + }) + + tID := testutil.Identifier(t) + testImageRef := fmt.Sprintf("127.0.0.1:%d/%s", reg.Port, tID) dockerfile := fmt.Sprintf(`FROM %s CMD ["echo", "nerdctl-build-test-string"] `, testutil.CommonImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", testImageRef, buildCtx).AssertOK() - base.Cmd("push", testImageRef, "--sign=cosign", "--cosign-key="+keyPair.privateKey).AssertOK() - base.Cmd("run", "--rm", "--verify=cosign", "--cosign-key="+keyPair.publicKey, testImageRef).AssertOK() + base.Cmd("push", testImageRef, "--sign=cosign", "--cosign-key="+keyPair.PrivateKey).AssertOK() + base.Cmd("run", "--rm", "--verify=cosign", "--cosign-key="+keyPair.PublicKey, testImageRef).AssertOK() base.Cmd("run", "--rm", "--verify=cosign", "--cosign-key=dummy", testImageRef).AssertFail() } diff --git a/cmd/nerdctl/container_run_windows.go b/cmd/nerdctl/container/container_run_windows.go similarity index 82% rename from cmd/nerdctl/container_run_windows.go rename to cmd/nerdctl/container/container_run_windows.go index e93f88cba9f..5ef9a6d94fb 100644 --- a/cmd/nerdctl/container_run_windows.go +++ b/cmd/nerdctl/container/container_run_windows.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "github.com/spf13/cobra" @@ -24,7 +24,3 @@ func capShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]s candidates := []string{} return candidates, cobra.ShellCompDirectiveNoFileComp } - -func runShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/nerdctl/container_run_windows_test.go b/cmd/nerdctl/container/container_run_windows_test.go similarity index 75% rename from cmd/nerdctl/container_run_windows_test.go rename to cmd/nerdctl/container/container_run_windows_test.go index 74bc189925e..843a6eac702 100644 --- a/cmd/nerdctl/container_run_windows_test.go +++ b/cmd/nerdctl/container/container_run_windows_test.go @@ -14,15 +14,19 @@ limitations under the License. */ -package main +package container import ( + "bytes" "os/exec" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" ) func TestRunHostProcessContainer(t *testing.T) { @@ -32,6 +36,7 @@ func TestRunHostProcessContainer(t *testing.T) { if err != nil { t.Fatalf("unable to get hostname: %s", err) } + hostname = bytes.TrimSpace(hostname) base.Cmd("run", "--rm", "--isolation=host", testutil.WindowsNano, "hostname").AssertOutContains(string(hostname)) output := base.Cmd("run", "--rm", "--isolation=host", testutil.WindowsNano, "whoami").Out() @@ -114,3 +119,29 @@ func TestRunProcessContainerWithDevice(t *testing.T) { "cmd", "/S", "/C", "dir C:\\Windows\\System32\\HostDriverStore", ).AssertOutContains("FileRepository") } + +func TestRunWithTtyAndDetached(t *testing.T) { + testCase := nerdtest.Setup() + + // This test is currently disabled, as it is failing most of the time. + testCase.Require = nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3437") + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // with -t, success, the container should run with tty support. + helpers.Ensure("run", "-d", "-t", "--name", data.Identifier("with-terminal"), testutil.CommonImage, "cmd", "/c", "echo", "Hello, World with TTY!") + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "rm", "-f", data.Identifier("with-terminal")) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + withTtyContainer := nerdtest.InspectContainer(helpers, data.Identifier("with-terminal")) + assert.Equal(helpers.T(), 0, withTtyContainer.State.ExitCode) + return helpers.Command("logs", data.Identifier("with-terminal")) + } + + testCase.Expected = test.Expects(0, nil, test.Contains("Hello, World with TTY!")) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_start.go b/cmd/nerdctl/container/container_start.go similarity index 81% rename from cmd/nerdctl/container_start.go rename to cmd/nerdctl/container/container_start.go index 8596adc3bea..1bf361b8e6a 100644 --- a/cmd/nerdctl/container_start.go +++ b/cmd/nerdctl/container/container_start.go @@ -14,18 +14,22 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/consoleutil" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/consoleutil" ) -func newStartCommand() *cobra.Command { +func NewStartCommand() *cobra.Command { var startCommand = &cobra.Command{ Use: "start [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -44,7 +48,7 @@ func newStartCommand() *cobra.Command { } func processContainerStartOptions(cmd *cobra.Command) (types.ContainerStartOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerStartOptions{}, err } @@ -84,5 +88,5 @@ func startShellComplete(cmd *cobra.Command, args []string, toComplete string) ([ statusFilterFn := func(st containerd.ProcessStatus) bool { return st != containerd.Running && st != containerd.Unknown } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container_start_linux_test.go b/cmd/nerdctl/container/container_start_linux_test.go similarity index 82% rename from cmd/nerdctl/container_start_linux_test.go rename to cmd/nerdctl/container/container_start_linux_test.go index 13ddf477626..4fe6f2d249c 100644 --- a/cmd/nerdctl/container_start_linux_test.go +++ b/cmd/nerdctl/container/container_start_linux_test.go @@ -14,27 +14,22 @@ limitations under the License. */ -package main +package container import ( "bytes" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestStartDetachKeys(t *testing.T) { t.Parallel() - if testutil.GetTarget() == testutil.Docker { - t.Skip("When detaching from a container, for a session started with 'docker attach'" + - ", it prints 'read escape sequence', but for one started with 'docker (run|start)', it prints nothing." + - " However, the flag is called '--detach-keys' in all cases" + - ", so nerdctl prints 'read detach keys' for all cases" + - ", and that's why this test is skipped for Docker.") - } + skipAttachForDocker(t) base := testutil.NewBase(t) containerName := testutil.Identifier(t) diff --git a/cmd/nerdctl/container_start_test.go b/cmd/nerdctl/container/container_start_test.go similarity index 95% rename from cmd/nerdctl/container_start_test.go rename to cmd/nerdctl/container/container_start_test.go index b9721b18241..60369433d24 100644 --- a/cmd/nerdctl/container_start_test.go +++ b/cmd/nerdctl/container/container_start_test.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package container import ( "runtime" "testing" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestStart(t *testing.T) { diff --git a/cmd/nerdctl/container/container_stats.go b/cmd/nerdctl/container/container_stats.go new file mode 100644 index 00000000000..10b927a750b --- /dev/null +++ b/cmd/nerdctl/container/container_stats.go @@ -0,0 +1,111 @@ +/* + 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 ( + "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" +) + +func NewStatsCommand() *cobra.Command { + var statsCommand = &cobra.Command{ + Use: "stats", + Short: "Display a live stream of container(s) resource usage statistics.", + RunE: statsAction, + ValidArgsFunction: statsShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } + + addStatsFlags(statsCommand) + + return statsCommand +} + +func addStatsFlags(cmd *cobra.Command) { + cmd.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") + cmd.Flags().String("format", "", "Pretty-print images using a Go template, e.g, '{{json .}}'") + cmd.Flags().Bool("no-stream", false, "Disable streaming stats and only pull the first result") + cmd.Flags().Bool("no-trunc", false, "Do not truncate output") +} + +func processStatsCommandFlags(cmd *cobra.Command) (types.ContainerStatsOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.ContainerStatsOptions{}, err + } + + all, err := cmd.Flags().GetBool("all") + if err != nil { + return types.ContainerStatsOptions{}, err + } + + noStream, err := cmd.Flags().GetBool("no-stream") + if err != nil { + return types.ContainerStatsOptions{}, err + } + + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.ContainerStatsOptions{}, err + } + + noTrunc, err := cmd.Flags().GetBool("no-trunc") + if err != nil { + return types.ContainerStatsOptions{}, err + } + + return types.ContainerStatsOptions{ + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + GOptions: globalOptions, + All: all, + Format: format, + NoStream: noStream, + NoTrunc: noTrunc, + }, nil +} + +func statsAction(cmd *cobra.Command, args []string) error { + options, err := processStatsCommandFlags(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 container.Stats(ctx, client, args, options) +} + +func statsShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // show running container names + statusFilterFn := func(st containerd.ProcessStatus) bool { + return st == containerd.Running + } + return completion.ContainerNames(cmd, statusFilterFn) +} diff --git a/cmd/nerdctl/container/container_stats_test.go b/cmd/nerdctl/container/container_stats_test.go new file mode 100644 index 00000000000..9522e73e009 --- /dev/null +++ b/cmd/nerdctl/container/container_stats_test.go @@ -0,0 +1,108 @@ +/* + 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 ( + "runtime" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestStats(t *testing.T) { + testCase := nerdtest.Setup() + + // FIXME: does not seem to work on windows + testCase.Require = test.Not(test.Windows) + + if runtime.GOOS == "linux" { + // this comment is for `nerdctl ps` but it also valid for `nerdctl stats` : + // https://github.com/containerd/nerdctl/pull/223#issuecomment-851395178 + testCase.Require = test.Require( + testCase.Require, + nerdtest.CgroupsAccessible, + ) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container")) + helpers.Anyhow("rm", "-f", data.Identifier("memlimited")) + helpers.Anyhow("rm", "-f", data.Identifier("exited")) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("container"), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("run", "-d", "--name", data.Identifier("memlimited"), "--memory", "1g", testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("run", "--name", data.Identifier("exited"), testutil.CommonImage, "echo", "'exited'") + data.Set("id", data.Identifier("container")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "stats", + Command: test.Command("stats", "--no-stream", "--no-trunc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("id")), + } + }, + }, + { + Description: "container stats", + Command: test.Command("container", "stats", "--no-stream", "--no-trunc"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("id")), + } + }, + }, + { + Description: "stats ID", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("stats", "--no-stream", data.Get("id")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "container stats ID", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "stats", "--no-stream", data.Get("id")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "no mem limit set", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("stats", "--no-stream") + }, + // https://github.com/containerd/nerdctl/issues/1240 + // nerdctl used to print UINT64_MAX as the memory limit, so, ensure it does no more + Expected: test.Expects(0, nil, test.DoesNotContain("16EiB")), + }, + { + Description: "mem limit set", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("stats", "--no-stream") + }, + Expected: test.Expects(0, nil, test.Contains("1GiB")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_stop.go b/cmd/nerdctl/container/container_stop.go similarity index 82% rename from cmd/nerdctl/container_stop.go rename to cmd/nerdctl/container/container_stop.go index 43a6f6bca44..cbc533b801f 100644 --- a/cmd/nerdctl/container_stop.go +++ b/cmd/nerdctl/container/container_stop.go @@ -14,19 +14,23 @@ limitations under the License. */ -package main +package container import ( "time" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newStopCommand() *cobra.Command { +func NewStopCommand() *cobra.Command { var stopCommand = &cobra.Command{ Use: "stop [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -41,7 +45,7 @@ func newStopCommand() *cobra.Command { } func processContainerStopOptions(cmd *cobra.Command) (types.ContainerStopOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerStopOptions{}, err } @@ -82,5 +86,5 @@ func stopShellComplete(cmd *cobra.Command, args []string, toComplete string) ([] statusFilterFn := func(st containerd.ProcessStatus) bool { return st != containerd.Stopped && st != containerd.Created && st != containerd.Unknown } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container_stop_linux_test.go b/cmd/nerdctl/container/container_stop_linux_test.go similarity index 50% rename from cmd/nerdctl/container_stop_linux_test.go rename to cmd/nerdctl/container/container_stop_linux_test.go index a3940188ca7..ab03dfd7e45 100644 --- a/cmd/nerdctl/container_stop_linux_test.go +++ b/cmd/nerdctl/container/container_stop_linux_test.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "fmt" @@ -22,10 +22,13 @@ import ( "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - + "github.com/coreos/go-iptables/iptables" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + iptablesutil "github.com/containerd/nerdctl/v2/pkg/testutil/iptables" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" ) func TestStopStart(t *testing.T) { @@ -70,6 +73,8 @@ func TestStopStart(t *testing.T) { func TestStopWithStopSignal(t *testing.T) { t.Parallel() + // There may be issues with logs in Docker. + // This test is flaky with Docker. Might be related to https://github.com/containerd/nerdctl/pull/3557 base := testutil.NewBase(t) testContainerName := testutil.Identifier(t) defer base.Cmd("rm", "-f", testContainerName).Run() @@ -86,3 +91,71 @@ echo "signal quit"`).AssertOK() base.Cmd("stop", testContainerName).AssertOK() base.Cmd("logs", "-f", testContainerName).AssertOutContains("signal quit") } + +func TestStopCleanupForwards(t *testing.T) { + const ( + hostPort = 9999 + testContainerName = "ngx" + ) + base := testutil.NewBase(t) + defer func() { + base.Cmd("rm", "-f", testContainerName).Run() + }() + + // skip if rootless + if rootlessutil.IsRootless() { + t.Skip("pkg/testutil/iptables does not support rootless") + } + + ipt, err := iptables.New() + assert.NilError(t, err) + + containerID := base.Cmd("run", "-d", + "--restart=no", + "--name", testContainerName, + "-p", fmt.Sprintf("127.0.0.1:%d:80", hostPort), + testutil.NginxAlpineImage).Run().Stdout() + containerID = strings.TrimSuffix(containerID, "\n") + + containerIP := base.Cmd("inspect", + "-f", + "'{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", + testContainerName).Run().Stdout() + containerIP = strings.ReplaceAll(containerIP, "'", "") + containerIP = strings.TrimSuffix(containerIP, "\n") + + // define iptables chain name depending on the target (docker/nerdctl) + var chain string + if testutil.GetTarget() == testutil.Docker { + chain = "DOCKER" + } else { + redirectChain := "CNI-HOSTPORT-DNAT" + chain = iptablesutil.GetRedirectedChain(t, ipt, redirectChain, testutil.Namespace, containerID) + } + assert.Equal(t, iptablesutil.ForwardExists(t, ipt, chain, containerIP, hostPort), true) + + base.Cmd("stop", testContainerName).AssertOK() + assert.Equal(t, iptablesutil.ForwardExists(t, ipt, chain, containerIP, hostPort), false) +} + +// Regression test for https://github.com/containerd/nerdctl/issues/3353 +func TestStopCreated(t *testing.T) { + t.Parallel() + + base := testutil.NewBase(t) + tID := testutil.Identifier(t) + + tearDown := func() { + base.Cmd("rm", "-f", tID).Run() + } + + setup := func() { + base.Cmd("create", "--name", tID, testutil.CommonImage).AssertOK() + } + + t.Cleanup(tearDown) + tearDown() + setup() + + base.Cmd("stop", tID).AssertOK() +} diff --git a/pkg/cmd/container/run_cgroup_freebsd.go b/cmd/nerdctl/container/container_test.go similarity index 74% rename from pkg/cmd/container/run_cgroup_freebsd.go rename to cmd/nerdctl/container/container_test.go index 7ae22379ef9..dcd08829fcd 100644 --- a/pkg/cmd/container/run_cgroup_freebsd.go +++ b/cmd/nerdctl/container/container_test.go @@ -17,10 +17,11 @@ package container import ( - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) -func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci.SpecOpts, error) { - return []oci.SpecOpts{}, nil +func TestMain(m *testing.M) { + testutil.M(m) } diff --git a/cmd/nerdctl/container_top.go b/cmd/nerdctl/container/container_top.go similarity index 77% rename from cmd/nerdctl/container_top.go rename to cmd/nerdctl/container/container_top.go index 141a200432e..0f20e45a2c0 100644 --- a/cmd/nerdctl/container_top.go +++ b/cmd/nerdctl/container/container_top.go @@ -14,23 +14,26 @@ limitations under the License. */ -package main +package container import ( "errors" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) -func newTopCommand() *cobra.Command { +func NewTopCommand() *cobra.Command { var topCommand = &cobra.Command{ Use: "top CONTAINER [ps OPTIONS]", Args: cobra.MinimumNArgs(1), @@ -47,7 +50,7 @@ func newTopCommand() *cobra.Command { func topAction(cmd *cobra.Command, args []string) error { // NOTE: rootless container does not rely on cgroupv1. // more details about possible ways to resolve this concern: #223 - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -75,5 +78,5 @@ func topShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]s statusFilterFn := func(st containerd.ProcessStatus) bool { return st == containerd.Running } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container/container_top_test.go b/cmd/nerdctl/container/container_top_test.go new file mode 100644 index 00000000000..8042b32a8b4 --- /dev/null +++ b/cmd/nerdctl/container/container_top_test.go @@ -0,0 +1,92 @@ +/* + 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 ( + "runtime" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestTop(t *testing.T) { + testCase := nerdtest.Setup() + + //more details https://github.com/containerd/nerdctl/pull/223#issuecomment-851395178 + if runtime.GOOS == "linux" { + testCase.Require = nerdtest.CgroupsAccessible + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // FIXME: busybox 1.36 on windows still appears to not support sleep inf. Unclear why. + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + data.Set("cID", data.Identifier()) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + } + + testCase.SubTests = []*test.Case{ + { + Description: "with o pid,user,cmd", + // Docker does not support top -o + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("top", data.Get("cID"), "-o", "pid,user,cmd") + }, + + Expected: test.Expects(0, nil, nil), + }, + { + Description: "simple", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("top", data.Get("cID")) + }, + + Expected: test.Expects(0, nil, nil), + }, + } + + testCase.Run(t) +} + +func TestTopHyperVContainer(t *testing.T) { + + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.HyperV + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // FIXME: busybox 1.36 on windows still appears to not support sleep inf. Unclear why. + helpers.Ensure("run", "--isolation", "hyperv", "-d", "--name", data.Identifier("container"), testutil.CommonImage, "sleep", nerdtest.Infinity) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("container")) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("top", data.Identifier("container")) + } + + testCase.Expected = test.Expects(0, nil, nil) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/container_unpause.go b/cmd/nerdctl/container/container_unpause.go similarity index 79% rename from cmd/nerdctl/container_unpause.go rename to cmd/nerdctl/container/container_unpause.go index 021d3d445b5..8d835c9388e 100644 --- a/cmd/nerdctl/container_unpause.go +++ b/cmd/nerdctl/container/container_unpause.go @@ -14,18 +14,21 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newUnpauseCommand() *cobra.Command { +func NewUnpauseCommand() *cobra.Command { var unpauseCommand = &cobra.Command{ Use: "unpause [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -39,7 +42,7 @@ func newUnpauseCommand() *cobra.Command { } func processContainerUnpauseOptions(cmd *cobra.Command) (types.ContainerUnpauseOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerUnpauseOptions{}, err } @@ -69,5 +72,5 @@ func unpauseShellComplete(cmd *cobra.Command, args []string, toComplete string) statusFilterFn := func(st containerd.ProcessStatus) bool { return st == containerd.Paused } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container_update.go b/cmd/nerdctl/container/container_update.go similarity index 81% rename from cmd/nerdctl/container_update.go rename to cmd/nerdctl/container/container_update.go index 7bebaf2b56c..0ce83e5a6c5 100644 --- a/cmd/nerdctl/container_update.go +++ b/cmd/nerdctl/container/container_update.go @@ -14,30 +14,34 @@ limitations under the License. */ -package main +package container import ( "context" + "encoding/json" "errors" "fmt" "runtime" + "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/log" - "github.com/containerd/containerd/pkg/cri/util" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - nerdctlContainer "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/typeurl/v2" "github.com/docker/go-units" runtimespec "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/containerd/typeurl/v2" + + "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" + nerdctlContainer "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/infoutil" ) type updateResourceOptions struct { @@ -53,7 +57,7 @@ type updateResourceOptions struct { BlkioWeight uint16 } -func newUpdateCommand() *cobra.Command { +func NewUpdateCommand() *cobra.Command { var updateCommand = &cobra.Command{ Use: "update [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -88,7 +92,7 @@ func setUpdateFlags(cmd *cobra.Command) { } func updateAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -196,7 +200,7 @@ func getUpdateOption(cmd *cobra.Command, globalOptions types.GlobalCommandOption return options, err } if kernelMemStr != "" && cmd.Flag("kernel-memory").Changed { - logrus.Warnf("The --kernel-memory flag is no longer supported. This flag is a noop.") + log.L.Warnf("The --kernel-memory flag is no longer supported. This flag is a noop.") } cpuset, err := cmd.Flags().GetString("cpuset-cpus") if err != nil { @@ -238,7 +242,7 @@ func getUpdateOption(cmd *cobra.Command, globalOptions types.GlobalCommandOption return options, nil } -func updateContainer(ctx context.Context, client *containerd.Client, id string, opts updateResourceOptions, cmd *cobra.Command) error { +func updateContainer(ctx context.Context, client *containerd.Client, id string, opts updateResourceOptions, cmd *cobra.Command) (retErr error) { container, err := client.LoadContainer(ctx, id) if err != nil { return err @@ -263,16 +267,18 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, if spec.Linux.Resources == nil { spec.Linux.Resources = &runtimespec.LinuxResources{} } - if spec.Linux.Resources.BlockIO == nil { - spec.Linux.Resources.BlockIO = &runtimespec.LinuxBlockIO{} - } if cmd.Flags().Changed("blkio-weight") { + if spec.Linux.Resources.BlockIO == nil { + spec.Linux.Resources.BlockIO = &runtimespec.LinuxBlockIO{} + } if spec.Linux.Resources.BlockIO.Weight != &opts.BlkioWeight { spec.Linux.Resources.BlockIO.Weight = &opts.BlkioWeight } } - if spec.Linux.Resources.CPU == nil { - spec.Linux.Resources.CPU = &runtimespec.LinuxCPU{} + if cmd.Flags().Changed("cpu-shares") || cmd.Flags().Changed("cpu-quota") || cmd.Flags().Changed("cpu-period") || cmd.Flags().Changed("cpus") || cmd.Flags().Changed("cpuset-mems") || cmd.Flags().Changed("cpuset-cpus") { + if spec.Linux.Resources.CPU == nil { + spec.Linux.Resources.CPU = &runtimespec.LinuxCPU{} + } } if cmd.Flags().Changed("cpu-shares") { if spec.Linux.Resources.CPU.Shares != &opts.CPUShares { @@ -305,8 +311,10 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, spec.Linux.Resources.CPU.Cpus = opts.CpusetCpus } } - if spec.Linux.Resources.Memory == nil { - spec.Linux.Resources.Memory = &runtimespec.LinuxMemory{} + if cmd.Flags().Changed("memory") || cmd.Flags().Changed("memory-reservation") { + if spec.Linux.Resources.Memory == nil { + spec.Linux.Resources.Memory = &runtimespec.LinuxMemory{} + } } if cmd.Flags().Changed("memory") { if spec.Linux.Resources.Memory.Limit != &opts.MemoryLimitInBytes { @@ -321,10 +329,10 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, spec.Linux.Resources.Memory.Reservation = &opts.MemoryReservation } } - if spec.Linux.Resources.Pids == nil { - spec.Linux.Resources.Pids = &runtimespec.LinuxPids{} - } if cmd.Flags().Changed("pids-limit") { + 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 } @@ -332,12 +340,18 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, } if err := updateContainerSpec(ctx, container, spec); err != nil { - log.G(ctx).WithError(err).Errorf("Failed to update spec %+v for container %q", spec, id) - // reset spec on error. - if err := updateContainerSpec(ctx, container, oldSpec); err != nil { - log.G(ctx).WithError(err).Errorf("Failed to update spec %+v for container %q", oldSpec, id) + return fmt.Errorf("failed to update spec %+v for container %q", spec, id) + } + defer func() { + if retErr != nil { + deferCtx, deferCancel := context.WithTimeout(ctx, 1*time.Minute) + defer deferCancel() + // Reset spec on error. + if err := updateContainerSpec(deferCtx, container, oldSpec); err != nil { + log.G(ctx).WithError(err).Errorf("Failed to update spec %+v for container %q", oldSpec, id) + } } - } + }() restart, err := cmd.Flags().GetString("restart") if err != nil { @@ -362,19 +376,16 @@ func updateContainer(ctx context.Context, client *containerd.Client, id string, } return fmt.Errorf("failed to get task:%w", err) } - if err := task.Update(ctx, containerd.WithResources(spec.Linux.Resources)); err != nil { - return err - } - return nil + return task.Update(ctx, containerd.WithResources(spec.Linux.Resources)) } func updateContainerSpec(ctx context.Context, container containerd.Container, spec *runtimespec.Spec) error { if err := container.Update(ctx, func(ctx context.Context, client *containerd.Client, c *containers.Container) error { - any, err := typeurl.MarshalAny(spec) + a, err := typeurl.MarshalAny(spec) if err != nil { return fmt.Errorf("failed to marshal spec %+v:%w", spec, err) } - c.Spec = any + c.Spec = a return nil }); err != nil { return fmt.Errorf("failed to update container spec:%w", err) @@ -384,12 +395,20 @@ func updateContainerSpec(ctx context.Context, container containerd.Container, sp func copySpec(spec *runtimespec.Spec) (*runtimespec.Spec, error) { var copySpec runtimespec.Spec - if err := util.DeepCopy(©Spec, spec); err != nil { - return nil, fmt.Errorf("failed to deep copy:%w", err) + if spec == nil { + return nil, errors.New("spec cannot be nil") + } + bytes, err := json.Marshal(spec) + if err != nil { + return nil, fmt.Errorf("unable to marshal spec: %w", err) + } + err = json.Unmarshal(bytes, ©Spec) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal into spec copy: %w", err) } return ©Spec, nil } func updateShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return shellCompleteContainerNames(cmd, nil) + return completion.ContainerNames(cmd, nil) } diff --git a/cmd/nerdctl/builder_linux_test.go b/cmd/nerdctl/container/container_update_linux_test.go similarity index 57% rename from cmd/nerdctl/builder_linux_test.go rename to cmd/nerdctl/container/container_update_linux_test.go index 5376698242d..a4091f4156a 100644 --- a/cmd/nerdctl/builder_linux_test.go +++ b/cmd/nerdctl/container/container_update_linux_test.go @@ -14,29 +14,20 @@ limitations under the License. */ -package main +package container import ( - "bytes" - "fmt" - "os" "testing" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) -func TestBuilderDebug(t *testing.T) { +func TestUpdateContainer(t *testing.T) { testutil.DockerIncompatible(t) + testContainerName := testutil.Identifier(t) base := testutil.NewBase(t) - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-builder-debug-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("builder", "debug", buildCtx).CmdOption(testutil.WithStdin(bytes.NewReader([]byte("c\n")))).AssertOK() + base.Cmd("run", "-d", "--name", testContainerName, testutil.CommonImage, "sleep", "infinity").AssertOK() + defer base.Cmd("rm", "-f", testContainerName).Run() + base.Cmd("update", "--memory", "999999999", "--restart", "123", testContainerName).AssertFail() + base.Cmd("inspect", "--mode=native", testContainerName).AssertOutNotContains(`"limit": 999999999,`) } diff --git a/cmd/nerdctl/container_wait.go b/cmd/nerdctl/container/container_wait.go similarity index 79% rename from cmd/nerdctl/container_wait.go rename to cmd/nerdctl/container/container_wait.go index ae4ce9ffc3d..c28168b090d 100644 --- a/cmd/nerdctl/container_wait.go +++ b/cmd/nerdctl/container/container_wait.go @@ -14,17 +14,21 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" "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/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" ) -func newWaitCommand() *cobra.Command { +func NewWaitCommand() *cobra.Command { var waitCommand = &cobra.Command{ Use: "wait [flags] CONTAINER [CONTAINER, ...]", Args: cobra.MinimumNArgs(1), @@ -38,7 +42,7 @@ func newWaitCommand() *cobra.Command { } func processContainerWaitOptions(cmd *cobra.Command) (types.ContainerWaitOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ContainerWaitOptions{}, err } @@ -68,5 +72,5 @@ func waitShellComplete(cmd *cobra.Command, args []string, toComplete string) ([] statusFilterFn := func(st containerd.ProcessStatus) bool { return st == containerd.Running } - return shellCompleteContainerNames(cmd, statusFilterFn) + return completion.ContainerNames(cmd, statusFilterFn) } diff --git a/cmd/nerdctl/container/container_wait_test.go b/cmd/nerdctl/container/container_wait_test.go new file mode 100644 index 00000000000..9c0c93da3ff --- /dev/null +++ b/cmd/nerdctl/container/container_wait_test.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 container + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestWait(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("1"), data.Identifier("2"), data.Identifier("3")) + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier("1"), testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", data.Identifier("2"), testutil.CommonImage, "sleep", "1") + helpers.Ensure("run", "-d", "--name", data.Identifier("3"), testutil.CommonImage, "sh", "-euxc", "sleep 5; exit 123") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("wait", data.Identifier("1"), data.Identifier("2"), data.Identifier("3")) + } + + testCase.Expected = test.Expects(0, nil, test.Equals(`0 +0 +123 +`)) + + testCase.Run(t) +} diff --git a/cmd/nerdctl/multi_platform_linux_test.go b/cmd/nerdctl/container/multi_platform_linux_test.go similarity index 83% rename from cmd/nerdctl/multi_platform_linux_test.go rename to cmd/nerdctl/container/multi_platform_linux_test.go index 017d2827c8b..3acb0e3ef87 100644 --- a/cmd/nerdctl/multi_platform_linux_test.go +++ b/cmd/nerdctl/container/multi_platform_linux_test.go @@ -14,19 +14,20 @@ limitations under the License. */ -package main +package container import ( "fmt" "io" - "os" "strings" "testing" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" ) func testMultiPlatformRun(base *testutil.Base, alpineImage string) { @@ -54,23 +55,21 @@ func TestMultiPlatformRun(t *testing.T) { func TestMultiPlatformBuildPush(t *testing.T) { testutil.DockerIncompatible(t) // non-buildx version of `docker build` lacks multi-platform. Also, `docker push` lacks --platform. testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) testutil.RequireExecPlatform(t, "linux/amd64", "linux/arm64", "linux/arm/v7") base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() + reg := testregistry.NewWithNoAuth(base, 0, false) + defer reg.Cleanup(nil) - imageName := fmt.Sprintf("localhost:%d/%s:latest", reg.ListenPort, tID) + imageName := fmt.Sprintf("localhost:%d/%s:latest", reg.Port, tID) defer base.Cmd("rmi", imageName).Run() dockerfile := fmt.Sprintf(`FROM %s RUN echo dummy `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, "--platform=amd64,arm64,linux/arm/v7", buildCtx).AssertOK() testMultiPlatformRun(base, imageName) @@ -83,23 +82,21 @@ RUN echo dummy func TestMultiPlatformBuildPushNoRun(t *testing.T) { testutil.DockerIncompatible(t) // non-buildx version of `docker build` lacks multi-platform. Also, `docker push` lacks --platform. testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) testutil.RequireExecPlatform(t, "linux/amd64", "linux/arm64", "linux/arm/v7") base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() + reg := testregistry.NewWithNoAuth(base, 0, false) + defer reg.Cleanup(nil) - imageName := fmt.Sprintf("localhost:%d/%s:latest", reg.ListenPort, tID) + imageName := fmt.Sprintf("localhost:%d/%s:latest", reg.Port, tID) defer base.Cmd("rmi", imageName).Run() dockerfile := fmt.Sprintf(`FROM %s CMD echo dummy `, testutil.AlpineImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) + buildCtx := helpers.CreateBuildContext(t, dockerfile) base.Cmd("build", "-t", imageName, "--platform=amd64,arm64,linux/arm/v7", buildCtx).AssertOK() testMultiPlatformRun(base, imageName) @@ -110,10 +107,10 @@ func TestMultiPlatformPullPushAllPlatforms(t *testing.T) { testutil.DockerIncompatible(t) base := testutil.NewBase(t) tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() + reg := testregistry.NewWithNoAuth(base, 0, false) + defer reg.Cleanup(nil) - pushImageName := fmt.Sprintf("localhost:%d/%s:latest", reg.ListenPort, tID) + pushImageName := fmt.Sprintf("localhost:%d/%s:latest", reg.Port, tID) defer base.Cmd("rmi", pushImageName).Run() base.Cmd("pull", "--all-platforms", testutil.AlpineImage).AssertOK() @@ -125,9 +122,9 @@ func TestMultiPlatformPullPushAllPlatforms(t *testing.T) { func TestMultiPlatformComposeUpBuild(t *testing.T) { testutil.DockerIncompatible(t) testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) testutil.RequireExecPlatform(t, "linux/amd64", "linux/arm64", "linux/arm/v7") base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() const dockerComposeYAML = ` services: diff --git a/cmd/nerdctl/container_commit_test.go b/cmd/nerdctl/container_commit_test.go deleted file mode 100644 index c48209f1d24..00000000000 --- a/cmd/nerdctl/container_commit_test.go +++ /dev/null @@ -1,55 +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 main - -import ( - "fmt" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestCommit(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - switch base.Info().CgroupDriver { - case "none", "": - t.Skip("requires cgroup (for pausing)") - } - testContainer := testutil.Identifier(t) - testImage := testutil.Identifier(t) + "-img" - defer base.Cmd("rm", "-f", testContainer).Run() - defer base.Cmd("rmi", testImage).Run() - - for _, pause := range []string{ - "true", - "false", - } { - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "infinity").AssertOK() - base.EnsureContainerStarted(testContainer) - base.Cmd("exec", testContainer, "sh", "-euxc", `echo hello-test-commit > /foo`).AssertOK() - base.Cmd( - "commit", - "-c", `CMD ["/foo"]`, - "-c", `ENTRYPOINT ["cat"]`, - fmt.Sprintf("--pause=%s", pause), - testContainer, testImage).AssertOK() - base.Cmd("run", "--rm", testImage).AssertOutExactly("hello-test-commit\n") - base.Cmd("rm", "-f", testContainer).Run() - base.Cmd("rmi", testImage).Run() - } -} diff --git a/cmd/nerdctl/container_cp.go b/cmd/nerdctl/container_cp.go deleted file mode 100644 index a80135b16bd..00000000000 --- a/cmd/nerdctl/container_cp.go +++ /dev/null @@ -1,68 +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 main - -import ( - "errors" - "path/filepath" - "strings" - - "github.com/spf13/cobra" -) - -var errFileSpecDoesntMatchFormat = errors.New("filespec must match the canonical format: [container:]file/path") - -func parseCpFileSpec(arg string) (*cpFileSpec, error) { - i := strings.Index(arg, ":") - - // filespec starting with a semicolon is invalid - if i == 0 { - return nil, errFileSpecDoesntMatchFormat - } - - if filepath.IsAbs(arg) { - // Explicit local absolute path, e.g., `C:\foo` or `/foo`. - return &cpFileSpec{ - Container: nil, - Path: arg, - }, nil - } - - parts := strings.SplitN(arg, ":", 2) - - if len(parts) == 1 || strings.HasPrefix(parts[0], ".") { - // Either there's no `:` in the arg - // OR it's an explicit local relative path like `./file:name.txt`. - return &cpFileSpec{ - Path: arg, - }, nil - } - - return &cpFileSpec{ - Container: &parts[0], - Path: parts[1], - }, nil -} - -type cpFileSpec struct { - Container *string - Path string -} - -func cpShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveFilterFileExt -} diff --git a/cmd/nerdctl/container_cp_linux_test.go b/cmd/nerdctl/container_cp_linux_test.go deleted file mode 100644 index 5442ed53b30..00000000000 --- a/cmd/nerdctl/container_cp_linux_test.go +++ /dev/null @@ -1,207 +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 main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "syscall" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestCopyToContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testContainer).Run() - - srcUID := os.Geteuid() - srcDir := t.TempDir() - srcFile := filepath.Join(srcDir, "test-file") - srcFileContent := []byte("test-file-content") - err := os.WriteFile(srcFile, srcFileContent, 0644) - assert.NilError(t, err) - - assertCat := func(catPath string) { - t.Logf("catPath=%q", catPath) - base.Cmd("exec", testContainer, "cat", catPath).AssertOutExactly(string(srcFileContent)) - base.Cmd("exec", testContainer, "stat", "-c", "%u", catPath).AssertOutExactly(fmt.Sprintf("%d\n", srcUID)) - } - - // For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ - t.Run("SRC_PATH specifies a file", func(t *testing.T) { - srcPath := srcFile - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := "/dest-no-exist-no-slash" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := destPath - assertCat(catPath) - }) - t.Run("DEST_PATH does not exist and ends with /", func(t *testing.T) { - destPath := "/dest-no-exist-with-slash/" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := "/dest-file-exists" - base.Cmd("exec", testContainer, "touch", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := destPath - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - destPath := "/dest-dir-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - }) - }) - t.Run("SRC_PATH specifies a directory", func(t *testing.T) { - srcPath := srcDir - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := "/dest2-no-exist" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := "/dest2-file-exists" - base.Cmd("exec", testContainer, "touch", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - t.Run("SRC_PATH does not end with `/.`", func(t *testing.T) { - destPath := "/dest2-dir-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, strings.TrimPrefix(srcFile, filepath.Dir(srcDir)+"/")) - assertCat(catPath) - }) - t.Run("SRC_PATH does end with `/.`", func(t *testing.T) { - srcPath += "/." - destPath := "/dest2-dir2-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - t.Logf("catPath=%q", catPath) - assertCat(catPath) - }) - }) - }) -} - -func TestCopyFromContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testContainer).Run() - - euid := os.Geteuid() - srcUID := 42 - srcDir := "/test-dir" - srcFile := filepath.Join(srcDir, "test-file") - srcFileContent := []byte("test-file-content") - mkSrcScript := fmt.Sprintf("mkdir -p %q && echo -n %q >%q && chown %d %q", srcDir, srcFileContent, srcFile, srcUID, srcFile) - base.Cmd("exec", testContainer, "sh", "-euc", mkSrcScript).AssertOK() - - assertCat := func(catPath string) { - t.Logf("catPath=%q", catPath) - got, err := os.ReadFile(catPath) - assert.NilError(t, err) - assert.DeepEqual(t, srcFileContent, got) - st, err := os.Stat(catPath) - assert.NilError(t, err) - stSys := st.Sys().(*syscall.Stat_t) - // stSys.Uid matches euid, not srcUID - assert.DeepEqual(t, uint32(euid), stSys.Uid) - } - - td := t.TempDir() - // For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ - t.Run("SRC_PATH specifies a file", func(t *testing.T) { - srcPath := srcFile - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := filepath.Join(td, "dest-no-exist-no-slash") - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := destPath - assertCat(catPath) - }) - t.Run("DEST_PATH does not exist and ends with /", func(t *testing.T) { - destPath := td + "/dest-no-exist-with-slash/" // Avoid filepath.Join, to forcibly append "/" - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := filepath.Join(td, "dest-file-exists") - err := os.WriteFile(destPath, []byte(""), 0644) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := destPath - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - destPath := filepath.Join(td, "dest-dir-exists") - err := os.Mkdir(destPath, 0755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - }) - }) - t.Run("SRC_PATH specifies a directory", func(t *testing.T) { - srcPath := srcDir - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-no-exist") - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-file-exists") - err := os.WriteFile(destPath, []byte(""), 0644) - assert.NilError(t, err) - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - t.Run("SRC_PATH does not end with `/.`", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-dir-exists") - err := os.Mkdir(destPath, 0755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, strings.TrimPrefix(srcFile, filepath.Dir(srcDir)+"/")) - assertCat(catPath) - }) - t.Run("SRC_PATH does end with `/.`", func(t *testing.T) { - srcPath += "/." - destPath := filepath.Join(td, "dest2-dir2-exists") - err := os.Mkdir(destPath, 0755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - }) - }) - }) -} diff --git a/cmd/nerdctl/container_create_linux_test.go b/cmd/nerdctl/container_create_linux_test.go deleted file mode 100644 index 403d463de8d..00000000000 --- a/cmd/nerdctl/container_create_linux_test.go +++ /dev/null @@ -1,139 +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 main - -import ( - "fmt" - "runtime" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "gotest.tools/v3/assert" -) - -func TestCreate(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - base.Cmd("create", "--name", tID, testutil.CommonImage, "echo", "foo").AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - base.Cmd("ps", "-a").AssertOutContains("Created") - base.Cmd("start", tID).AssertOK() - base.Cmd("logs", tID).AssertOutContains("foo") -} - -func TestCreateWithMACAddress(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - networkBridge := "testNetworkBridge" + tID - networkMACvlan := "testNetworkMACvlan" + tID - networkIPvlan := "testNetworkIPvlan" + tID - base.Cmd("network", "create", networkBridge, "--driver", "bridge").AssertOK() - base.Cmd("network", "create", networkMACvlan, "--driver", "macvlan").AssertOK() - base.Cmd("network", "create", networkIPvlan, "--driver", "ipvlan").AssertOK() - t.Cleanup(func() { - base.Cmd("network", "rm", networkBridge).Run() - base.Cmd("network", "rm", networkMACvlan).Run() - base.Cmd("network", "rm", networkIPvlan).Run() - }) - tests := []struct { - Network string - WantErr bool - Expect string - }{ - {"host", true, "conflicting options"}, - {"none", true, "can't open '/sys/class/net/eth0/address'"}, - {"container:whatever" + tID, true, "conflicting options"}, - {"bridge", false, ""}, - {networkBridge, false, ""}, - {networkMACvlan, false, ""}, - {networkIPvlan, true, "not support"}, - } - for i, test := range tests { - containerName := fmt.Sprintf("%s_%d", tID, i) - testName := fmt.Sprintf("%s_container:%s_network:%s_expect:%s", tID, containerName, test.Network, test.Expect) - t.Run(testName, func(tt *testing.T) { - macAddress, err := nettestutil.GenerateMACAddress() - if err != nil { - tt.Errorf("failed to generate MAC address: %s", err) - } - if test.Expect == "" && !test.WantErr { - test.Expect = macAddress - } - tt.Cleanup(func() { - base.Cmd("rm", "-f", containerName).Run() - }) - cmd := base.Cmd("create", "--network", test.Network, "--mac-address", macAddress, "--name", containerName, testutil.CommonImage, "cat", "/sys/class/net/eth0/address") - if !test.WantErr { - cmd.AssertOK() - base.Cmd("start", containerName).AssertOK() - cmd = base.Cmd("logs", containerName) - cmd.AssertOK() - cmd.AssertOutContains(test.Expect) - } else { - if (testutil.GetTarget() == testutil.Docker && test.Network == networkIPvlan) || test.Network == "none" { - // 1. unlike nerdctl - // when using network ipvlan in Docker - // it delays fail on executing start command - // 2. start on network none will success in both - // nerdctl and Docker - cmd.AssertOK() - cmd = base.Cmd("start", containerName) - if test.Network == "none" { - // we check the result on logs command - cmd.AssertOK() - cmd = base.Cmd("logs", containerName) - } - } - cmd.AssertCombinedOutContains(test.Expect) - if test.Network == "none" { - cmd.AssertOK() - } else { - cmd.AssertFail() - } - } - }) - } -} - -func TestCreateWithTty(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("json-file log driver is not yet implemented on Windows") - } - base := testutil.NewBase(t) - imageName := testutil.CommonImage - withoutTtyContainerName := "without-terminal-" + testutil.Identifier(t) - withTtyContainerName := "with-terminal-" + testutil.Identifier(t) - - // without -t, fail - base.Cmd("create", "--name", withoutTtyContainerName, imageName, "stty").AssertOK() - base.Cmd("start", withoutTtyContainerName).AssertOK() - defer base.Cmd("container", "rm", "-f", withoutTtyContainerName).AssertOK() - base.Cmd("logs", withoutTtyContainerName).AssertCombinedOutContains("stty: standard input: Not a tty") - withoutTtyContainer := base.InspectContainer(withoutTtyContainerName) - assert.Equal(base.T, 1, withoutTtyContainer.State.ExitCode) - - // with -t, success - base.Cmd("create", "-t", "--name", withTtyContainerName, imageName, "stty").AssertOK() - base.Cmd("start", withTtyContainerName).AssertOK() - defer base.Cmd("container", "rm", "-f", withTtyContainerName).AssertOK() - base.Cmd("logs", withTtyContainerName).AssertCombinedOutContains("speed 38400 baud; line = 0;") - withTtyContainer := base.InspectContainer(withTtyContainerName) - assert.Equal(base.T, 0, withTtyContainer.State.ExitCode) -} diff --git a/cmd/nerdctl/container_create_windows_test.go b/cmd/nerdctl/container_create_windows_test.go deleted file mode 100644 index e8f662895ac..00000000000 --- a/cmd/nerdctl/container_create_windows_test.go +++ /dev/null @@ -1,57 +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 main - -import ( - "testing" - "time" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestCreateProcessContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - base.Cmd("create", "--name", tID, testutil.CommonImage, "echo", "foo").AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - base.Cmd("ps", "-a").AssertOutContains("Created") - base.Cmd("start", tID).AssertOK() - base.Cmd("logs", tID).AssertOutContains("foo") -} - -func TestCreateHyperVContainer(t *testing.T) { - //t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - if !testutil.HyperVSupported() { - t.Skip("HyperV is not enabled, skipping test") - } - - base.Cmd("create", "--isolation", "hyperv", "--name", tID, testutil.CommonImage, "echo", "foo").AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - base.Cmd("ps", "-a").AssertOutContains("Created") - - base.Cmd("start", tID).AssertOK() - // hyperv containers take a few seconds to fire up, the test would fail without the sleep - // EnsureContainerStarted does not work - time.Sleep(10 * time.Second) - - base.Cmd("logs", tID).AssertOutContains("foo") -} diff --git a/cmd/nerdctl/container_list.go b/cmd/nerdctl/container_list.go deleted file mode 100644 index 8c8ad9011dc..00000000000 --- a/cmd/nerdctl/container_list.go +++ /dev/null @@ -1,128 +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 main - -import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - - "github.com/spf13/cobra" -) - -func newPsCommand() *cobra.Command { - var psCommand = &cobra.Command{ - Use: "ps", - Args: cobra.NoArgs, - Short: "List containers", - RunE: psAction, - SilenceUsage: true, - SilenceErrors: true, - } - psCommand.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") - psCommand.Flags().IntP("last", "n", -1, "Show n last created containers (includes all states)") - psCommand.Flags().BoolP("latest", "l", false, "Show the latest created container (includes all states)") - psCommand.Flags().Bool("no-trunc", false, "Don't truncate output") - psCommand.Flags().BoolP("quiet", "q", false, "Only display container IDs") - psCommand.Flags().BoolP("size", "s", false, "Display total file sizes") - - // Alias "-f" is reserved for "--filter" - psCommand.Flags().String("format", "", "Format the output using the given Go template, e.g, '{{json .}}', 'wide'") - psCommand.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "table", "wide"}, cobra.ShellCompDirectiveNoFileComp - }) - psCommand.Flags().StringSliceP("filter", "f", nil, "Filter matches containers based on given conditions") - return psCommand -} - -func processContainerListOptions(cmd *cobra.Command) (types.ContainerListOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) - if err != nil { - return types.ContainerListOptions{}, err - } - all, err := cmd.Flags().GetBool("all") - if err != nil { - return types.ContainerListOptions{}, err - } - latest, err := cmd.Flags().GetBool("latest") - if err != nil { - return types.ContainerListOptions{}, err - } - - lastN, err := cmd.Flags().GetInt("last") - if err != nil { - return types.ContainerListOptions{}, err - } - if lastN == -1 && latest { - lastN = 1 - } - - filters, err := cmd.Flags().GetStringSlice("filter") - if err != nil { - return types.ContainerListOptions{}, err - } - - noTrunc, err := cmd.Flags().GetBool("no-trunc") - if err != nil { - return types.ContainerListOptions{}, err - } - trunc := !noTrunc - - quiet, err := cmd.Flags().GetBool("quiet") - if err != nil { - return types.ContainerListOptions{}, err - } - format, err := cmd.Flags().GetString("format") - if err != nil { - return types.ContainerListOptions{}, err - } - - size := false - if !quiet { - size, err = cmd.Flags().GetBool("size") - if err != nil { - return types.ContainerListOptions{}, err - } - } - - return types.ContainerListOptions{ - Stdout: cmd.OutOrStdout(), - GOptions: globalOptions, - All: all, - LastN: lastN, - Truncate: trunc, - Quiet: quiet, - Size: size, - Format: format, - Filters: filters, - }, nil -} - -func psAction(cmd *cobra.Command, args []string) error { - options, err := processContainerListOptions(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 container.List(ctx, client, options) -} diff --git a/cmd/nerdctl/container_prune_linux_test.go b/cmd/nerdctl/container_prune_linux_test.go deleted file mode 100644 index bfcda034719..00000000000 --- a/cmd/nerdctl/container_prune_linux_test.go +++ /dev/null @@ -1,42 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestPruneContainer(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - base.Cmd("run", "-d", "--name", tID+"-1", testutil.CommonImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID+"-1").Run() - base.Cmd("create", "--name", tID+"-2", testutil.CommonImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID+"-2").Run() - - base.Cmd("container", "prune", "-f").AssertOK() - // tID-1 is still running, tID-2 is not - base.Cmd("inspect", tID+"-1").AssertOK() - base.Cmd("inspect", tID+"-2").AssertFail() - - base.Cmd("kill", tID+"-1").AssertOK() - base.Cmd("container", "prune", "-f").AssertOK() - base.Cmd("inspect", tID+"-1").AssertFail() -} diff --git a/cmd/nerdctl/container_stats_linux_test.go b/cmd/nerdctl/container_stats_linux_test.go deleted file mode 100644 index 738bb855551..00000000000 --- a/cmd/nerdctl/container_stats_linux_test.go +++ /dev/null @@ -1,45 +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 main - -import ( - "fmt" - "testing" - - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestStats(t *testing.T) { - // this comment is for `nerdctl ps` but it also valid for `nerdctl stats` : - // https://github.com/containerd/nerdctl/pull/223#issuecomment-851395178 - if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { - t.Skip("test skipped for rootless containers on cgroup v1") - } - testContainerName := testutil.Identifier(t)[:12] - exitedTestContainerName := fmt.Sprintf("%s-exited", testContainerName) - - base := testutil.NewBase(t) - defer base.Cmd("rm", "-f", testContainerName).Run() - defer base.Cmd("rm", "-f", exitedTestContainerName).Run() - base.Cmd("run", "--name", exitedTestContainerName, testutil.AlpineImage, "echo", "'exited'").AssertOK() - - base.Cmd("run", "-d", "--name", testContainerName, testutil.AlpineImage, "sleep", "5").AssertOK() - base.Cmd("stats", "--no-stream").AssertOutContains(testContainerName) - base.Cmd("stats", "--no-stream", testContainerName).AssertOK() -} diff --git a/cmd/nerdctl/container_top_unix_test.go b/cmd/nerdctl/container_top_unix_test.go deleted file mode 100644 index acf3676362b..00000000000 --- a/cmd/nerdctl/container_top_unix_test.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build linux || darwin || freebsd || netbsd || openbsd - -/* - 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 ( - "testing" - - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestTop(t *testing.T) { - t.Parallel() - //more details https://github.com/containerd/nerdctl/pull/223#issuecomment-851395178 - if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { - t.Skip("test skipped for rootless containers on cgroup v1") - } - testContainerName := testutil.Identifier(t) - - base := testutil.NewBase(t) - defer base.Cmd("rm", "-f", testContainerName).Run() - - base.Cmd("run", "-d", "--name", testContainerName, testutil.AlpineImage, "sleep", "5").AssertOK() - base.Cmd("top", testContainerName, "-o", "pid,user,cmd").AssertOK() - -} diff --git a/cmd/nerdctl/container_top_windows_test.go b/cmd/nerdctl/container_top_windows_test.go deleted file mode 100644 index f862cf328ae..00000000000 --- a/cmd/nerdctl/container_top_windows_test.go +++ /dev/null @@ -1,47 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestTopProcessContainer(t *testing.T) { - testContainerName := testutil.Identifier(t) - - base := testutil.NewBase(t) - defer base.Cmd("rm", "-f", testContainerName).Run() - - base.Cmd("run", "-d", "--name", testContainerName, testutil.WindowsNano, "sleep", "5").AssertOK() - base.Cmd("top", testContainerName).AssertOK() -} - -func TestTopHyperVContainer(t *testing.T) { - if !testutil.HyperVSupported() { - t.Skip("HyperV is not enabled, skipping test") - } - - testContainerName := testutil.Identifier(t) - - base := testutil.NewBase(t) - defer base.Cmd("rm", "-f", testContainerName).Run() - - base.Cmd("run", "--isolation", "hyperv", "-d", "--name", testContainerName, testutil.WindowsNano, "sleep", "5").AssertOK() - base.Cmd("top", testContainerName).AssertOK() -} diff --git a/cmd/nerdctl/container_wait_test.go b/cmd/nerdctl/container_wait_test.go deleted file mode 100644 index 6a580cf143b..00000000000 --- a/cmd/nerdctl/container_wait_test.go +++ /dev/null @@ -1,47 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestWait(t *testing.T) { - t.Parallel() - tID := testutil.Identifier(t) - testContainerName1 := tID + "-1" - testContainerName2 := tID + "-2" - testContainerName3 := tID + "-3" - - const expected = `0 -0 -123 -` - base := testutil.NewBase(t) - defer base.Cmd("rm", "-f", testContainerName1, testContainerName2, testContainerName3).Run() - - base.Cmd("run", "-d", "--name", testContainerName1, testutil.CommonImage, "sleep", "1").AssertOK() - - base.Cmd("run", "-d", "--name", testContainerName2, testutil.CommonImage, "sleep", "1").AssertOK() - - base.Cmd("run", "--name", testContainerName3, testutil.CommonImage, "sh", "-euxc", "sleep 5; exit 123").AssertExitCode(123) - - base.Cmd("wait", testContainerName1, testContainerName2, testContainerName3).AssertOutExactly(expected) - -} diff --git a/cmd/nerdctl/helpers/cobra.go b/cmd/nerdctl/helpers/cobra.go new file mode 100644 index 00000000000..d35030ea8cf --- /dev/null +++ b/cmd/nerdctl/helpers/cobra.go @@ -0,0 +1,285 @@ +/* + 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 helpers + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/containerd/log" +) + +// UnknownSubcommandAction is needed to let `nerdctl system non-existent-command` fail +// https://github.com/containerd/nerdctl/issues/487 +// +// Ideally this should be implemented in Cobra itself. +func UnknownSubcommandAction(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + // The output mimics https://github.com/spf13/cobra/blob/v1.2.1/command.go#L647-L662 + msg := fmt.Sprintf("unknown subcommand %q for %q", args[0], cmd.Name()) + if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { + msg += "\n\nDid you mean this?\n" + for _, s := range suggestions { + msg += fmt.Sprintf("\t%v\n", s) + } + } + return errors.New(msg) +} + +// IsExactArgs returns an error if there is not the exact number of args +func IsExactArgs(number int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) == number { + return nil + } + return fmt.Errorf( + "%q requires exactly %d %s.\nSee '%s --help'.\n\nUsage: %s\n\n%s", + cmd.CommandPath(), + number, + "argument(s)", + cmd.CommandPath(), + cmd.UseLine(), + cmd.Short, + ) + } +} + +// AddStringFlag is similar to cmd.Flags().String but supports aliases and env var +func AddStringFlag(cmd *cobra.Command, name string, aliases []string, value string, env, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + value = envV + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new(string) + flags := cmd.Flags() + flags.StringVar(p, name, value, usage) + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.StringVarP(p, a, a, value, aliasesUsage) + } else { + flags.StringVar(p, a, value, aliasesUsage) + } + } +} + +// AddIntFlag is similar to cmd.Flags().Int but supports aliases and env var +func AddIntFlag(cmd *cobra.Command, name string, aliases []string, value int, env, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + v, err := strconv.ParseInt(envV, 10, 64) + if err != nil { + log.L.WithError(err).Warnf("Invalid int value for `%s`", env) + } + value = int(v) + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new(int) + flags := cmd.Flags() + flags.IntVar(p, name, value, usage) + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.IntVarP(p, a, a, value, aliasesUsage) + } else { + flags.IntVar(p, a, value, aliasesUsage) + } + } +} + +// AddDurationFlag is similar to cmd.Flags().Duration but supports aliases and env var +func AddDurationFlag(cmd *cobra.Command, name string, aliases []string, value time.Duration, env, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + var err error + value, err = time.ParseDuration(envV) + if err != nil { + log.L.WithError(err).Warnf("Invalid duration value for `%s`", env) + } + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new(time.Duration) + flags := cmd.Flags() + flags.DurationVar(p, name, value, usage) + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.DurationVarP(p, a, a, value, aliasesUsage) + } else { + flags.DurationVar(p, a, value, aliasesUsage) + } + } +} + +func GlobalFlags(cmd *cobra.Command) (string, []string) { + args0, err := os.Executable() + if err != nil { + log.L.WithError(err).Warnf("cannot call os.Executable(), assuming the executable to be %q", os.Args[0]) + args0 = os.Args[0] + } + if len(os.Args) < 2 { + return args0, nil + } + + rootCmd := cmd.Root() + flagSet := rootCmd.Flags() + args := []string{} + flagSet.VisitAll(func(f *pflag.Flag) { + key := f.Name + val := f.Value.String() + if f.Changed { + args = append(args, "--"+key+"="+val) + } + }) + return args0, args +} + +// AddPersistentStringArrayFlag is similar to cmd.Flags().StringArray but supports aliases and env var and persistent. +// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". +func AddPersistentStringArrayFlag(cmd *cobra.Command, name string, aliases, nonPersistentAliases []string, value []string, env string, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + value = []string{envV} + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new([]string) + flags := cmd.Flags() + for _, a := range nonPersistentAliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.StringArrayVarP(p, a, a, value, aliasesUsage) + } else { + flags.StringArrayVar(p, a, value, aliasesUsage) + } + } + + persistentFlags := cmd.PersistentFlags() + persistentFlags.StringArrayVar(p, name, value, usage) + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + persistentFlags.StringArrayVarP(p, a, a, value, aliasesUsage) + } else { + persistentFlags.StringArrayVar(p, a, value, aliasesUsage) + } + } +} + +// AddPersistentStringFlag is similar to AddStringFlag but persistent. +// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". +func AddPersistentStringFlag(cmd *cobra.Command, name string, aliases, localAliases, persistentAliases []string, aliasToBeInherited *pflag.FlagSet, value string, env, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + value = envV + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new(string) + + // flags is full set of flag(s) + // flags can redefine alias already used in subcommands + flags := cmd.Flags() + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.StringVarP(p, a, a, value, aliasesUsage) + } else { + flags.StringVar(p, a, value, aliasesUsage) + } + // non-persistent flags are not added to the InheritedFlags, so we should add them manually + f := flags.Lookup(a) + aliasToBeInherited.AddFlag(f) + } + + // localFlags are local to the rootCmd + localFlags := cmd.LocalFlags() + for _, a := range localAliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + localFlags.StringVarP(p, a, a, value, aliasesUsage) + } else { + localFlags.StringVar(p, a, value, aliasesUsage) + } + } + + // persistentFlags cannot redefine alias already used in subcommands + persistentFlags := cmd.PersistentFlags() + persistentFlags.StringVar(p, name, value, usage) + for _, a := range persistentAliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + persistentFlags.StringVarP(p, a, a, value, aliasesUsage) + } else { + persistentFlags.StringVar(p, a, value, aliasesUsage) + } + } +} + +// AddPersistentBoolFlag is similar to AddBoolFlag but persistent. +// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". +func AddPersistentBoolFlag(cmd *cobra.Command, name string, aliases, nonPersistentAliases []string, value bool, env, usage string) { + if env != "" { + usage = fmt.Sprintf("%s [$%s]", usage, env) + } + if envV, ok := os.LookupEnv(env); ok { + var err error + value, err = strconv.ParseBool(envV) + if err != nil { + log.L.WithError(err).Warnf("Invalid boolean value for `%s`", env) + } + } + aliasesUsage := fmt.Sprintf("Alias of --%s", name) + p := new(bool) + flags := cmd.Flags() + for _, a := range nonPersistentAliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + flags.BoolVarP(p, a, a, value, aliasesUsage) + } else { + flags.BoolVar(p, a, value, aliasesUsage) + } + } + + persistentFlags := cmd.PersistentFlags() + persistentFlags.BoolVar(p, name, value, usage) + for _, a := range aliases { + if len(a) == 1 { + // pflag doesn't support short-only flags, so we have to register long one as well here + persistentFlags.BoolVarP(p, a, a, value, aliasesUsage) + } else { + persistentFlags.BoolVar(p, a, value, aliasesUsage) + } + } +} diff --git a/cmd/nerdctl/helpers/consts.go b/cmd/nerdctl/helpers/consts.go new file mode 100644 index 00000000000..ff1cc06ad99 --- /dev/null +++ b/cmd/nerdctl/helpers/consts.go @@ -0,0 +1,22 @@ +/* + 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 helpers + +const ( + Category = "category" + Management = "management" +) diff --git a/cmd/nerdctl/flagutil.go b/cmd/nerdctl/helpers/flagutil.go similarity index 79% rename from cmd/nerdctl/flagutil.go rename to cmd/nerdctl/helpers/flagutil.go index 2c6102a5401..871b018a024 100644 --- a/cmd/nerdctl/flagutil.go +++ b/cmd/nerdctl/helpers/flagutil.go @@ -14,27 +14,17 @@ limitations under the License. */ -package main +package helpers import ( - "github.com/containerd/nerdctl/pkg/api/types" + "fmt" + "github.com/spf13/cobra" -) -func processImageSignOptions(cmd *cobra.Command) (opt types.ImageSignOptions, err error) { - if opt.Provider, err = cmd.Flags().GetString("sign"); err != nil { - return - } - if opt.CosignKey, err = cmd.Flags().GetString("cosign-key"); err != nil { - return - } - if opt.NotationKeyName, err = cmd.Flags().GetString("notation-key-name"); err != nil { - return - } - return -} + "github.com/containerd/nerdctl/v2/pkg/api/types" +) -func processImageVerifyOptions(cmd *cobra.Command) (opt types.ImageVerifyOptions, err error) { +func ProcessImageVerifyOptions(cmd *cobra.Command) (opt types.ImageVerifyOptions, err error) { if opt.Provider, err = cmd.Flags().GetString("verify"); err != nil { return } @@ -56,7 +46,7 @@ func processImageVerifyOptions(cmd *cobra.Command) (opt types.ImageVerifyOptions return } -func processRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) { +func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) { debug, err := cmd.Flags().GetBool("debug") if err != nil { return types.GlobalCommandOptions{}, err @@ -109,6 +99,14 @@ func processRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) if err != nil { return types.GlobalCommandOptions{}, err } + bridgeIP, err := cmd.Flags().GetString("bridge-ip") + if err != nil { + return types.GlobalCommandOptions{}, err + } + kubeHideDupe, err := cmd.Flags().GetBool("kube-hide-dupe") + if err != nil { + return types.GlobalCommandOptions{}, err + } return types.GlobalCommandOptions{ Debug: debug, DebugFull: debugFull, @@ -123,5 +121,20 @@ func processRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) HostsDir: hostsDir, Experimental: experimental, HostGatewayIP: hostGatewayIP, + BridgeIP: bridgeIP, + KubeHideDupe: kubeHideDupe, }, nil } + +func CheckExperimental(feature string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + globalOptions, err := ProcessRootCmdFlags(cmd) + if err != nil { + return err + } + if !globalOptions.Experimental { + return fmt.Errorf("%s is experimental feature, you should enable experimental config", feature) + } + return nil + } +} diff --git a/cmd/nerdctl/completion_linux.go b/cmd/nerdctl/helpers/prompt.go similarity index 62% rename from cmd/nerdctl/completion_linux.go rename to cmd/nerdctl/helpers/prompt.go index e89d5e40960..9254699f7a6 100644 --- a/cmd/nerdctl/completion_linux.go +++ b/cmd/nerdctl/helpers/prompt.go @@ -14,21 +14,26 @@ limitations under the License. */ -package main +package helpers import ( - "github.com/containerd/nerdctl/pkg/apparmorutil" + "fmt" + "strings" + "github.com/spf13/cobra" ) -func shellCompleteApparmorProfiles(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { - profiles, err := apparmorutil.Profiles() +func Confirm(cmd *cobra.Command, message string) (bool, error) { + message += "\nAre you sure you want to continue? [y/N] " + _, err := fmt.Fprint(cmd.OutOrStdout(), message) if err != nil { - return nil, cobra.ShellCompDirectiveError + return false, err } - var names []string // nolint: prealloc - for _, f := range profiles { - names = append(names, f.Name) + + var confirm string + _, err = fmt.Fscanf(cmd.InOrStdin(), "%s", &confirm) + if err != nil { + return false, err } - return names, cobra.ShellCompDirectiveNoFileComp + return strings.ToLower(confirm) == "y", err } diff --git a/cmd/nerdctl/helpers/testing.go b/cmd/nerdctl/helpers/testing.go new file mode 100644 index 00000000000..c7f460b19a0 --- /dev/null +++ b/cmd/nerdctl/helpers/testing.go @@ -0,0 +1,137 @@ +/* + 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 helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func CreateBuildContext(t *testing.T, dockerfile string) string { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "Dockerfile"), []byte(dockerfile), 0644) + assert.NilError(t, err) + return tmpDir +} + +func RmiAll(base *testutil.Base) { + base.T.Logf("Pruning images") + imageIDs := base.Cmd("images", "--no-trunc", "-a", "-q").OutLines() + // remove empty output line at the end + imageIDs = imageIDs[:len(imageIDs)-1] + // use `Run` on purpose (same below) because `rmi all` may fail on individual + // image id that has an expected running container (e.g. a registry) + base.Cmd(append([]string{"rmi", "-f"}, imageIDs...)...).Run() + + base.T.Logf("Pruning build caches") + if _, err := buildkitutil.GetBuildkitHost(testutil.Namespace); err == nil { + base.Cmd("builder", "prune", "--force").AssertOK() + } + + // For BuildKit >= 0.11, pruning cache isn't enough to remove manifest blobs that are referred by build history blobs + // https://github.com/containerd/nerdctl/pull/1833 + if base.Target == testutil.Nerdctl { + base.T.Logf("Pruning all content blobs") + addr := base.ContainerdAddress() + client, err := containerd.New(addr, containerd.WithDefaultNamespace(testutil.Namespace)) + assert.NilError(base.T, err) + cs := client.ContentStore() + ctx := context.TODO() + wf := func(info content.Info) error { + base.T.Logf("Pruning blob %+v", info) + if err := cs.Delete(ctx, info.Digest); err != nil { + base.T.Log(err) + } + return nil + } + if err := cs.Walk(ctx, wf); err != nil { + base.T.Log(err) + } + + base.T.Logf("Pruning all images (again?)") + imageIDs = base.Cmd("images", "--no-trunc", "-a", "-q").OutLines() + base.T.Logf("pruning following images: %+v", imageIDs) + base.Cmd(append([]string{"rmi", "-f"}, imageIDs...)...).Run() + } +} + +func ExtractDockerArchive(archiveTarPath, rootfsPath string) error { + if err := os.MkdirAll(rootfsPath, 0755); err != nil { + return err + } + workDir, err := os.MkdirTemp("", "extract-docker-archive") + if err != nil { + return err + } + defer os.RemoveAll(workDir) + if err := ExtractTarFile(workDir, archiveTarPath); err != nil { + return err + } + manifestJSONPath := filepath.Join(workDir, "manifest.json") + manifestJSONBytes, err := os.ReadFile(manifestJSONPath) + if err != nil { + return err + } + var mani DockerArchiveManifestJSON + if err := json.Unmarshal(manifestJSONBytes, &mani); err != nil { + return err + } + if len(mani) > 1 { + return fmt.Errorf("multi-image archive cannot be extracted: contains %d images", len(mani)) + } + if len(mani) < 1 { + return errors.New("invalid archive") + } + ent := mani[0] + for _, l := range ent.Layers { + layerTarPath := filepath.Join(workDir, l) + if err := ExtractTarFile(rootfsPath, layerTarPath); err != nil { + return err + } + } + return nil +} + +type DockerArchiveManifestJSON []DockerArchiveManifestJSONEntry + +type DockerArchiveManifestJSONEntry struct { + Config string + RepoTags []string + Layers []string +} + +func ExtractTarFile(dirPath, tarFilePath string) error { + cmd := exec.Command("tar", "Cxf", dirPath, tarFilePath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err) + } + return nil +} diff --git a/cmd/nerdctl/helpers/testing_linux.go b/cmd/nerdctl/helpers/testing_linux.go new file mode 100644 index 00000000000..c50e16ae06c --- /dev/null +++ b/cmd/nerdctl/helpers/testing_linux.go @@ -0,0 +1,182 @@ +/* + 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 helpers + +import ( + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" +) + +func FindIPv6(output string) net.IP { + var ipv6 string + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "inet6") { + fields := strings.Fields(line) + if len(fields) > 1 { + ipv6 = strings.Split(fields[1], "/")[0] + break + } + } + } + return net.ParseIP(ipv6) +} + +func RequiresStargz(base *testutil.Base) { + info := base.Info() + for _, p := range info.Plugins.Storage { + if p == "stargz" { + return + } + } + base.T.Skip("test requires stargz") +} + +type JweKeyPair struct { + Prv string + Pub string + Cleanup func() +} + +func NewJWEKeyPair(t testing.TB) *JweKeyPair { + testutil.RequireExecutable(t, "openssl") + td, err := os.MkdirTemp(t.TempDir(), "jwe-key-pair") + assert.NilError(t, err) + prv := filepath.Join(td, "mykey.pem") + pub := filepath.Join(td, "mypubkey.pem") + cmds := [][]string{ + // Exec openssl commands to ensure that nerdctl is compatible with the output of openssl commands. + // Do NOT refactor this function to use "crypto/rsa" stdlib. + {"openssl", "genrsa", "-out", prv}, + {"openssl", "rsa", "-in", prv, "-pubout", "-out", pub}, + } + for _, f := range cmds { + cmd := exec.Command(f[0], f[1:]...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run %v: %v (%q)", cmd.Args, err, string(out)) + } + } + return &JweKeyPair{ + Prv: prv, + Pub: pub, + Cleanup: func() { + _ = os.RemoveAll(td) + }, + } +} + +func RequiresSoci(base *testutil.Base) { + info := base.Info() + for _, p := range info.Plugins.Storage { + if p == "soci" { + return + } + } + base.T.Skip("test requires soci") +} + +type CosignKeyPair struct { + PublicKey string + PrivateKey string + Cleanup func() +} + +func NewCosignKeyPair(t testing.TB, path string, password string) *CosignKeyPair { + td, err := os.MkdirTemp(t.TempDir(), path) + assert.NilError(t, err) + + cmd := exec.Command("cosign", "generate-key-pair") + cmd.Dir = td + cmd.Env = append(cmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", password)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run %v: %v (%q)", cmd.Args, err, string(out)) + } + + publicKey := filepath.Join(td, "cosign.pub") + privateKey := filepath.Join(td, "cosign.key") + + return &CosignKeyPair{ + PublicKey: publicKey, + PrivateKey: privateKey, + Cleanup: func() { + _ = os.RemoveAll(td) + }, + } +} + +func ComposeUp(t *testing.T, base *testutil.Base, dockerComposeYAML string, opts ...string) { + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + base.ComposeCmd(append(append([]string{"-f", comp.YAMLFullPath()}, opts...), "up", "-d")...).AssertOK() + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + base.Cmd("volume", "inspect", fmt.Sprintf("%s_db", projectName)).AssertOK() + 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) + if err != nil { + return err + } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if !strings.Contains(string(respBody), testutil.WordpressIndexHTMLSnippet) { + t.Logf("respBody=%q", respBody) + return fmt.Errorf("respBody does not contain %q", testutil.WordpressIndexHTMLSnippet) + } + return nil + } + + var wordpressWorking bool + for i := 0; i < 30; i++ { + t.Logf("(retry %d)", i) + err := checkWordpress() + if err == nil { + wordpressWorking = true + break + } + // NOTE: "

Error establishing a database connection

" is expected for the first few iterations + t.Log(err) + time.Sleep(3 * time.Second) + } + + if !wordpressWorking { + t.Fatal("wordpress is not working") + } + t.Log("wordpress seems functional") + + base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + base.Cmd("volume", "inspect", fmt.Sprintf("%s_db", projectName)).AssertFail() + base.Cmd("network", "inspect", fmt.Sprintf("%s_default", projectName)).AssertFail() +} diff --git a/cmd/nerdctl/image.go b/cmd/nerdctl/image/image.go similarity index 71% rename from cmd/nerdctl/image.go rename to cmd/nerdctl/image/image.go index afae1936335..3c4bc48eeb3 100644 --- a/cmd/nerdctl/image.go +++ b/cmd/nerdctl/image/image.go @@ -14,31 +14,34 @@ limitations under the License. */ -package main +package image import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/builder" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newImageCommand() *cobra.Command { +func NewImageCommand() *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "image", Short: "Manage images", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } cmd.AddCommand( - newBuildCommand(), + builder.NewBuildCommand(), // commitCommand is in "container", not in "image" imageLsCommand(), - newHistoryCommand(), - newPullCommand(), - newPushCommand(), - newLoadCommand(), - newSaveCommand(), - newTagCommand(), + NewHistoryCommand(), + NewPullCommand(), + NewPushCommand(), + NewLoadCommand(), + NewSaveCommand(), + NewTagCommand(), imageRmCommand(), newImageConvertCommand(), newImageInspectCommand(), @@ -50,14 +53,14 @@ func newImageCommand() *cobra.Command { } func imageLsCommand() *cobra.Command { - x := newImagesCommand() + x := NewImagesCommand() x.Use = "ls" x.Aliases = []string{"list"} return x } func imageRmCommand() *cobra.Command { - x := newRmiCommand() + x := NewRmiCommand() x.Use = "rm" x.Aliases = []string{"remove"} return x diff --git a/cmd/nerdctl/image_convert.go b/cmd/nerdctl/image/image_convert.go similarity index 90% rename from cmd/nerdctl/image_convert.go rename to cmd/nerdctl/image/image_convert.go index b65565fae26..5b181580da6 100644 --- a/cmd/nerdctl/image_convert.go +++ b/cmd/nerdctl/image/image_convert.go @@ -14,15 +14,18 @@ limitations under the License. */ -package main +package image import ( "compress/gzip" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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" ) const imageConvertHelp = `Convert an image format. @@ -60,6 +63,11 @@ func newImageConvertCommand() *cobra.Command { imageConvertCommand.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')") // #endregion + // #region zstd flags + imageConvertCommand.Flags().Bool("zstd", false, "Convert legacy tar(.gz) layers to zstd. Should be used in conjunction with '--oci'") + imageConvertCommand.Flags().Int("zstd-compression-level", 3, "zstd compression level") + // #endregion + // #region zstd:chunked flags imageConvertCommand.Flags().Bool("zstdchunked", false, "Convert legacy tar(.gz) layers to zstd:chunked for lazy pulling. Should be used in conjunction with '--oci'") imageConvertCommand.Flags().String("zstdchunked-record-in", "", "Read 'ctr-remote optimize --record-out=' record file (EXPERIMENTAL)") @@ -89,7 +97,7 @@ func newImageConvertCommand() *cobra.Command { // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" imageConvertCommand.Flags().StringSlice("platform", []string{}, "Convert content for a specific platform") - imageConvertCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + imageConvertCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) imageConvertCommand.Flags().Bool("all-platforms", false, "Convert content for all platforms") // #endregion @@ -97,7 +105,7 @@ func newImageConvertCommand() *cobra.Command { } func processImageConvertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageConvertOptions{}, err } @@ -137,6 +145,17 @@ func processImageConvertOptions(cmd *cobra.Command) (types.ImageConvertOptions, } // #endregion + // #region zstd flags + zstd, err := cmd.Flags().GetBool("zstd") + if err != nil { + return types.ImageConvertOptions{}, err + } + zstdCompressionLevel, err := cmd.Flags().GetInt("zstd-compression-level") + if err != nil { + return types.ImageConvertOptions{}, err + } + // #endregion + // #region zstd:chunked flags zstdchunked, err := cmd.Flags().GetBool("zstdchunked") if err != nil { @@ -227,6 +246,10 @@ func processImageConvertOptions(cmd *cobra.Command) (types.ImageConvertOptions, EstargzExternalToc: estargzExternalTOC, EstargzKeepDiffID: estargzKeepDiffID, // #endregion + // #region zstd flags + Zstd: zstd, + ZstdCompressionLevel: zstdCompressionLevel, + // #endregion // #region zstd:chunked flags ZstdChunked: zstdchunked, ZstdChunkedCompressionLevel: zstdChunkedCompressionLevel, @@ -276,5 +299,5 @@ func imageConvertAction(cmd *cobra.Command, args []string) error { func imageConvertShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image/image_convert_linux_test.go b/cmd/nerdctl/image/image_convert_linux_test.go new file mode 100644 index 00000000000..6968e5ab7a5 --- /dev/null +++ b/cmd/nerdctl/image/image_convert_linux_test.go @@ -0,0 +1,143 @@ +/* + 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" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" +) + +func TestImageConvert(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + // FIXME: windows does not support stargz + test.Not(test.Windows), + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + }, + SubTests: []*test.Case{ + { + Description: "esgz", + 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", "--oci", "--estargz", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "nydus", + Require: test.Require( + test.Binary("nydus-image"), + ), + 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", "--oci", "--nydus", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "zstd", + 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", "--oci", "--zstd", "--zstd-compression-level", "3", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "zstdchunked", + 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", "--oci", "--zstdchunked", "--zstdchunked-compression-level", "3", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(0, nil, nil), + }, + }, + } + + testCase.Run(t) + +} + +func TestImageConvertNydusVerify(t *testing.T) { + nerdtest.Setup() + + const remoteImageKey = "remoteImageKey" + + var registry *testregistry.RegistryServer + + testCase := &test.Case{ + Require: test.Require( + test.Linux, + test.Binary("nydus-image"), + test.Binary("nydusify"), + test.Binary("nydusd"), + test.Not(nerdtest.Docker), + nerdtest.Rootful, + ), + 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.Set(remoteImageKey, fmt.Sprintf("%s:%d/nydusd-image:test", "localhost", registry.Port)) + helpers.Ensure("image", "convert", "--nydus", "--oci", testutil.CommonImage, data.Identifier("converted-image")) + helpers.Ensure("tag", data.Identifier("converted-image"), data.Get(remoteImageKey)) + helpers.Ensure("push", data.Get(remoteImageKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) + if registry != nil { + registry.Cleanup(nil) + helpers.Anyhow("rmi", "-f", data.Get(remoteImageKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("nydusify", + "check", + "--source", + testutil.CommonImage, + "--target", + data.Get(remoteImageKey), + "--source-insecure", + "--target-insecure", + ) + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_cryptutil.go b/cmd/nerdctl/image/image_cryptutil.go similarity index 90% rename from cmd/nerdctl/image_cryptutil.go rename to cmd/nerdctl/image/image_cryptutil.go index 039f7720205..0268bddb001 100644 --- a/cmd/nerdctl/image_cryptutil.go +++ b/cmd/nerdctl/image/image_cryptutil.go @@ -14,13 +14,16 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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" ) // registerImgcryptFlags register flags that correspond to parseImgcryptFlags(). @@ -35,7 +38,7 @@ func registerImgcryptFlags(cmd *cobra.Command, encrypt bool) { // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" flags.StringSlice("platform", []string{}, "Convert content for a specific platform") - cmd.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + cmd.RegisterFlagCompletionFunc("platform", completion.Platforms) flags.Bool("all-platforms", false, "Convert content for all platforms") // #endregion @@ -53,7 +56,7 @@ func registerImgcryptFlags(cmd *cobra.Command, encrypt bool) { } func processImgCryptOptions(cmd *cobra.Command, args []string, encrypt bool) (types.ImageCryptOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageCryptOptions{}, err } @@ -122,5 +125,5 @@ func getImgcryptAction(encrypt bool) func(cmd *cobra.Command, args []string) err func imgcryptShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image_decrypt.go b/cmd/nerdctl/image/image_decrypt.go similarity index 99% rename from cmd/nerdctl/image_decrypt.go rename to cmd/nerdctl/image/image_decrypt.go index fa6faf46629..6830d137d73 100644 --- a/cmd/nerdctl/image_decrypt.go +++ b/cmd/nerdctl/image/image_decrypt.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package image import ( "github.com/spf13/cobra" diff --git a/cmd/nerdctl/image_encrypt.go b/cmd/nerdctl/image/image_encrypt.go similarity index 99% rename from cmd/nerdctl/image_encrypt.go rename to cmd/nerdctl/image/image_encrypt.go index 39ee30659f3..7c2ef199cbc 100644 --- a/cmd/nerdctl/image_encrypt.go +++ b/cmd/nerdctl/image/image_encrypt.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package image import ( "github.com/spf13/cobra" diff --git a/cmd/nerdctl/image/image_encrypt_linux_test.go b/cmd/nerdctl/image/image_encrypt_linux_test.go new file mode 100644 index 00000000000..3e2e93ac29e --- /dev/null +++ b/cmd/nerdctl/image/image_encrypt_linux_test.go @@ -0,0 +1,82 @@ +/* + 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" + "strings" + "testing" + + "gotest.tools/v3/assert" + + testhelpers "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" +) + +func TestImageEncryptJWE(t *testing.T) { + nerdtest.Setup() + + var registry *testregistry.RegistryServer + var keyPair *testhelpers.JweKeyPair + + const remoteImageKey = "remoteImageKey" + + testCase := &test.Case{ + Require: test.Require( + test.Linux, + test.Not(nerdtest.Docker), + // This test needs to rmi the common image + nerdtest.Private, + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + if registry != nil { + registry.Cleanup(nil) + keyPair.Cleanup() + helpers.Anyhow("rmi", "-f", data.Get(remoteImageKey)) + } + helpers.Anyhow("rmi", "-f", data.Identifier("decrypted")) + }, + Setup: func(data test.Data, helpers test.Helpers) { + base := testutil.NewBase(t) + registry = testregistry.NewWithNoAuth(base, 0, false) + keyPair = testhelpers.NewJWEKeyPair(t) + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + encryptImageRef := fmt.Sprintf("127.0.0.1:%d/%s:encrypted", registry.Port, data.Identifier()) + helpers.Ensure("image", "encrypt", "--recipient=jwe:"+keyPair.Pub, testutil.CommonImage, encryptImageRef) + inspector := helpers.Capture("image", "inspect", "--mode=native", "--format={{len .Index.Manifests}}", encryptImageRef) + assert.Equal(t, inspector, "1\n") + inspector = helpers.Capture("image", "inspect", "--mode=native", "--format={{json .Manifest.Layers}}", encryptImageRef) + assert.Assert(t, strings.Contains(inspector, "org.opencontainers.image.enc.keys.jwe")) + helpers.Ensure("push", encryptImageRef) + helpers.Anyhow("rmi", "-f", encryptImageRef) + helpers.Anyhow("rmi", "-f", testutil.CommonImage) + data.Set(remoteImageKey, encryptImageRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Fail("pull", data.Get(remoteImageKey)) + helpers.Ensure("pull", "--quiet", "--unpack=false", data.Get(remoteImageKey)) + helpers.Fail("image", "decrypt", "--key="+keyPair.Pub, data.Get(remoteImageKey), data.Identifier("decrypted")) // decryption needs prv key, not pub key + return helpers.Command("image", "decrypt", "--key="+keyPair.Prv, data.Get(remoteImageKey), data.Identifier("decrypted")) + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_history.go b/cmd/nerdctl/image/image_history.go similarity index 69% rename from cmd/nerdctl/image_history.go rename to cmd/nerdctl/image/image_history.go index 2550b19ab66..d4f4aa9bcc8 100644 --- a/cmd/nerdctl/image_history.go +++ b/cmd/nerdctl/image/image_history.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package image import ( "bytes" @@ -23,26 +23,31 @@ import ( "fmt" "io" "os" + "strconv" "text/tabwriter" "text/template" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/imgutil" + "github.com/docker/go-units" "github.com/opencontainers/image-spec/identity" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "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/formatter" + "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil" ) -func newHistoryCommand() *cobra.Command { +func NewHistoryCommand() *cobra.Command { var historyCommand = &cobra.Command{ Use: "history [flags] IMAGE", Short: "Show the history of an image", - Args: IsExactArgs(1), + Args: helpers.IsExactArgs(1), RunE: historyAction, ValidArgsFunction: historyShellComplete, SilenceUsage: true, @@ -58,11 +63,16 @@ func addHistoryFlags(cmd *cobra.Command) { return []string{"json"}, cobra.ShellCompDirectiveNoFileComp }) cmd.Flags().BoolP("quiet", "q", false, "Only show numeric IDs") + cmd.Flags().BoolP("human", "H", true, "Print sizes and dates in human readable format (default true)") cmd.Flags().Bool("no-trunc", false, "Don't truncate output") } type historyPrintable struct { + creationTime *time.Time + size int64 + Snapshot string + CreatedAt string CreatedSince string CreatedBy string Size string @@ -70,7 +80,7 @@ type historyPrintable struct { } func historyAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -101,7 +111,7 @@ func historyAction(cmd *cobra.Command, args []string) error { } var historys []historyPrintable for _, h := range configHistories { - var size string + var size int64 var snapshotName string if !h.EmptyLayer { if len(diffIDs) <= layerCounter { @@ -119,18 +129,18 @@ func historyAction(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to get usage: %w", err) } - size = progress.Bytes(use.Size).String() + size = use.Size snapshotName = stat.Name layerCounter++ } else { - size = progress.Bytes(0).String() + size = 0 snapshotName = "" } history := historyPrintable{ + creationTime: h.Created, + size: size, Snapshot: snapshotName, - CreatedSince: formatter.TimeSinceInHuman(*h.Created), CreatedBy: h.CreatedBy, - Size: size, Comment: h.Comment, } historys = append(historys, history) @@ -147,9 +157,9 @@ func historyAction(cmd *cobra.Command, args []string) error { } type historyPrinter struct { - w io.Writer - quiet, noTrunc bool - tmpl *template.Template + w io.Writer + quiet, noTrunc, human bool + tmpl *template.Template } func printHistory(cmd *cobra.Command, historys []historyPrintable) error { @@ -161,6 +171,11 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { if err != nil { return err } + human, err := cmd.Flags().GetBool("human") + if err != nil { + return err + } + var w io.Writer w = os.Stdout @@ -179,9 +194,7 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { case "raw": return errors.New("unsupported format: \"raw\"") default: - if quiet { - return errors.New("format and quiet must not be specified together") - } + quiet = false var err error tmpl, err = formatter.ParseTemplate(format) if err != nil { @@ -193,12 +206,13 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { w: w, quiet: quiet, noTrunc: noTrunc, + human: human, tmpl: tmpl, } for index := len(historys) - 1; index >= 0; index-- { if err := printer.printHistory(historys[index]); err != nil { - logrus.Warn(err) + log.L.Warn(err) } } @@ -208,31 +222,47 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { return nil } -func (x *historyPrinter) printHistory(p historyPrintable) error { +func (x *historyPrinter) printHistory(printable historyPrintable) error { + // Truncate long values unless --no-trunc is passed if !x.noTrunc { - if len(p.CreatedBy) > 45 { - p.CreatedBy = p.CreatedBy[0:44] + "…" + if len(printable.CreatedBy) > 45 { + printable.CreatedBy = printable.CreatedBy[0:44] + "…" + } + // Do not truncate snapshot id if quiet is being passed + if !x.quiet && len(printable.Snapshot) > 45 { + printable.Snapshot = printable.Snapshot[0:44] + "…" } } + + // Format date and size for display based on --human preference + printable.CreatedAt = printable.creationTime.Local().Format(time.RFC3339) + if x.human { + printable.CreatedSince = formatter.TimeSinceInHuman(*printable.creationTime) + printable.Size = units.HumanSize(float64(printable.size)) + } else { + printable.CreatedSince = printable.CreatedAt + printable.Size = strconv.FormatInt(printable.size, 10) + } + if x.tmpl != nil { var b bytes.Buffer - if err := x.tmpl.Execute(&b, p); err != nil { + if err := x.tmpl.Execute(&b, printable); err != nil { return err } - if _, err := fmt.Fprintf(x.w, b.String()+"\n"); err != nil { + if _, err := fmt.Fprintln(x.w, b.String()); err != nil { return err } } else if x.quiet { - if _, err := fmt.Fprintf(x.w, "%s\n", p.Snapshot); err != nil { + if _, err := fmt.Fprintln(x.w, printable.Snapshot); err != nil { return err } } else { if _, err := fmt.Fprintf(x.w, "%s\t%s\t%s\t%s\t%s\n", - p.Snapshot, - p.CreatedSince, - p.CreatedBy, - p.Size, - p.Comment, + printable.Snapshot, + printable.CreatedSince, + printable.CreatedBy, + printable.Size, + printable.Comment, ); err != nil { return err } @@ -242,5 +272,5 @@ func (x *historyPrinter) printHistory(p historyPrintable) error { func historyShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image/image_history_test.go b/cmd/nerdctl/image/image_history_test.go new file mode 100644 index 00000000000..9520e72535b --- /dev/null +++ b/cmd/nerdctl/image/image_history_test.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. +*/ + +package image + +import ( + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +type historyObj struct { + Snapshot string + CreatedAt string + CreatedSince string + CreatedBy string + Size string + Comment string +} + +func decode(stdout string) ([]historyObj, error) { + dec := json.NewDecoder(strings.NewReader(stdout)) + object := []historyObj{} + for { + var v historyObj + if err := dec.Decode(&v); err == io.EOF { + break + } else if err != nil { + return nil, errors.New("failed to decode history object") + } + object = append(object, v) + } + + return object, nil +} + +func TestImageHistory(t *testing.T) { + // Here are the current issues with regard to docker true compatibility: + // - we have a different definition of what a layer id is (snapshot vs. id) + // this will require indepth convergence when moby will handle multi-platform images + // - our definition of size is different + // this requires some investigation to figure out why it differs + // possibly one is unpacked on the filessystem while the other is the tar file size? + // - we do not truncate ids when --quiet has been provided + // this is a conscious decision here - truncating with --quiet does not make much sense + + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + test.Not(nerdtest.Docker), + // XXX the results here are obviously platform dependent - and it seems like windows cannot pull a linux image? + test.Not(test.Windows), + // XXX Currently, history does not work on non-native platform, so, we cannot test reliably on other platforms + test.Arm64, + ), + Setup: func(data test.Data, helpers test.Helpers) { + // XXX: despite efforts to isolate this test, it keeps on having side effects linked to + // https://github.com/containerd/nerdctl/issues/3512 + // Isolating it into a completely different root is the last ditched attempt at avoiding the issue + helpers.Write(nerdtest.DataRoot, test.ConfigValue(data.TempDir())) + helpers.Ensure("pull", "--quiet", "--platform", "linux/arm64", testutil.CommonImage) + }, + SubTests: []*test.Case{ + { + 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) { + history, err := decode(stdout) + assert.NilError(t, err, info) + assert.Equal(t, len(history), 2, info) + assert.Equal(t, history[0].Size, "0B", info) + // FIXME: how is this going to age? + assert.Equal(t, history[0].CreatedSince, "3 years ago", info) + assert.Equal(t, history[0].Snapshot, "", info) + assert.Equal(t, history[0].Comment, "", 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[1].Size, "5.947MB", info) + assert.Equal(t, history[1].CreatedSince, "3 years ago", info) + assert.Equal(t, history[1].Snapshot, "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c…", info) + assert.Equal(t, history[1].Comment, "", info) + }), + }, + { + Description: "no human - dates and sizes and 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) { + 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) + }), + }, + { + 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) { + 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 / ") + }), + }, + { + 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") + }), + }, + { + 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") + }), + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_inspect.go b/cmd/nerdctl/image/image_inspect.go similarity index 84% rename from cmd/nerdctl/image_inspect.go rename to cmd/nerdctl/image/image_inspect.go index a255324ba2c..c24d8cf64e3 100644 --- a/cmd/nerdctl/image_inspect.go +++ b/cmd/nerdctl/image/image_inspect.go @@ -14,13 +14,16 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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 newImageInspectCommand() *cobra.Command { @@ -45,14 +48,14 @@ func newImageInspectCommand() *cobra.Command { // #region platform flags imageInspectCommand.Flags().String("platform", "", "Inspect a specific platform") // not a slice, and there is no --all-platforms - imageInspectCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + imageInspectCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) // #endregion return imageInspectCommand } -func processImageInspectOptions(cmd *cobra.Command, platform *string) (types.ImageInspectOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) +func ProcessImageInspectOptions(cmd *cobra.Command, platform *string) (types.ImageInspectOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageInspectOptions{}, err } @@ -81,7 +84,7 @@ func processImageInspectOptions(cmd *cobra.Command, platform *string) (types.Ima } func imageInspectAction(cmd *cobra.Command, args []string) error { - options, err := processImageInspectOptions(cmd, nil) + options, err := ProcessImageInspectOptions(cmd, nil) if err != nil { return err } @@ -97,5 +100,5 @@ func imageInspectAction(cmd *cobra.Command, args []string) error { func imageInspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image/image_inspect_test.go b/cmd/nerdctl/image/image_inspect_test.go new file mode 100644 index 00000000000..808fd199d46 --- /dev/null +++ b/cmd/nerdctl/image/image_inspect_test.go @@ -0,0 +1,235 @@ +/* + 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 ( + "encoding/json" + "errors" + "fmt" + "runtime" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "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/test" +) + +func TestImageInspectSimpleCases(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.CommonImage) + }, + SubTests: []*test.Case{ + { + Description: "Contains some stuff", + Command: test.Command("image", "inspect", testutil.CommonImage), + Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.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) + }), + }, + { + Description: "RawFormat support (.Id)", + Command: test.Command("image", "inspect", testutil.CommonImage, "--format", "{{.Id}}"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "typedFormat support (.ID)", + Command: test.Command("image", "inspect", testutil.CommonImage, "--format", "{{.ID}}"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "Error for image not found", + Command: test.Command("image", "inspect", "dne:latest", "dne2:latest"), + Expected: test.Expects(1, []error{ + errors.New("no such image: dne:latest"), + errors.New("no such image: dne2:latest"), + }, nil), + }, + }, + } + + if runtime.GOOS == "windows" { + testCase.Require = nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3524") + } + + testCase.Run(t) +} + +func TestImageInspectDifferentValidReferencesForTheSameImage(t *testing.T) { + nerdtest.Setup() + + tags := []string{ + "", + ":latest", + } + names := []string{ + "busybox", + "docker.io/library/busybox", + "registry-1.docker.io/library/busybox", + } + + testCase := &test.Case{ + Require: test.Require( + test.Not(nerdtest.Docker), + // FIXME: this test depends on hub images that do not have windows versions + test.Not(test.Windows), + // We need a clean slate + nerdtest.Private, + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", "alpine") + helpers.Ensure("pull", "--quiet", "busybox") + helpers.Ensure("pull", "--quiet", "registry-1.docker.io/library/busybox") + }, + SubTests: []*test.Case{ + { + Description: "name and tags +/- sha combinations", + 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) { + 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) + reference := dc[0].ID + sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") + + for _, name := range names { + for _, tag := range tags { + it := nerdtest.InspectImage(helpers, name+tag) + assert.Equal(t, it.ID, reference) + it = nerdtest.InspectImage(helpers, name+tag+"@sha256:"+sha) + assert.Equal(t, it.ID, reference) + } + } + }, + } + }, + }, + { + Description: "by digest, short or long, with or without prefix", + 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) { + 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) + reference := dc[0].ID + sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") + + for _, id := range []string{"sha256:" + sha, sha, sha[0:8], "sha256:" + sha[0:8]} { + it := nerdtest.InspectImage(helpers, id) + assert.Equal(t, it.ID, reference) + } + + // Now, tag alpine with a short id + // Build reference values for comparison + alpine := nerdtest.InspectImage(helpers, "alpine") + + // Demonstrate image name precedence over digest lookup + // Using the shortened sha should no longer get busybox, but rather the newly tagged Alpine + // FIXME: this is triggering https://github.com/containerd/nerdctl/issues/3016 + // We cannot get rid of that image now, which does break local testing + helpers.Ensure("tag", "alpine", sha[0:8]) + it := nerdtest.InspectImage(helpers, sha[0:8]) + assert.Equal(t, it.ID, alpine.ID) + }, + } + }, + }, + { + Description: "prove that wrong references with correct digest do not get resolved", + 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) { + 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) + sha := strings.TrimPrefix(dc[0].RepoDigests[0], "busybox@sha256:") + + for _, id := range []string{"doesnotexist", "doesnotexist:either", "busybox:bogustag"} { + cmd := helpers.Command("image", "inspect", id+"@sha256:"+sha) + cmd.Run(&test.Expected{ + ExitCode: 1, + Errors: []error{fmt.Errorf("no such image: %s@sha256:%s", id, sha)}, + }) + } + }, + } + }, + }, + { + Description: "prove that invalid reference return no result without crashing", + 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) { + 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) + + for _, id := range []string{"∞∞∞∞∞∞∞∞∞∞", "busybox:∞∞∞∞∞∞∞∞∞∞"} { + cmd := helpers.Command("image", "inspect", id) + cmd.Run(&test.Expected{ + ExitCode: 1, + Errors: []error{fmt.Errorf("invalid reference format: %s", id)}, + }) + } + }, + } + }, + }, + { + Description: "retrieving multiple entries at once", + 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) { + 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) + reference := nerdtest.InspectImage(helpers, "busybox") + assert.Equal(t, dc[0].ID, reference.ID) + assert.Equal(t, dc[1].ID, reference.ID) + }, + } + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_list.go b/cmd/nerdctl/image/image_list.go similarity index 81% rename from cmd/nerdctl/image_list.go rename to cmd/nerdctl/image/image_list.go index e63f14fc6fd..06566f471fd 100644 --- a/cmd/nerdctl/image_list.go +++ b/cmd/nerdctl/image/image_list.go @@ -14,19 +14,22 @@ limitations under the License. */ -package main +package image import ( "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" - "github.com/containerd/nerdctl/pkg/referenceutil" "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" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) -func newImagesCommand() *cobra.Command { +func NewImagesCommand() *cobra.Command { shortHelp := "List images" longHelp := shortHelp + ` @@ -67,49 +70,47 @@ Properties: return imagesCommand } -func processImageListOptions(cmd *cobra.Command, args []string) (types.ImageListOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) +func processImageListOptions(cmd *cobra.Command, args []string) (*types.ImageListOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { - return types.ImageListOptions{}, err + return nil, err } var filters []string - if len(args) > 0 { - canonicalRef, err := referenceutil.ParseAny(args[0]) + parsedReference, err := referenceutil.Parse(args[0]) if err != nil { - return types.ImageListOptions{}, err + return nil, err } - filters = append(filters, fmt.Sprintf("name==%s", canonicalRef.String())) - filters = append(filters, fmt.Sprintf("name==%s", args[0])) + filters = []string{fmt.Sprintf("name==%s", parsedReference)} } quiet, err := cmd.Flags().GetBool("quiet") if err != nil { - return types.ImageListOptions{}, err + return nil, err } noTrunc, err := cmd.Flags().GetBool("no-trunc") if err != nil { - return types.ImageListOptions{}, err + return nil, err } format, err := cmd.Flags().GetString("format") if err != nil { - return types.ImageListOptions{}, err + return nil, err } var inputFilters []string if cmd.Flags().Changed("filter") { inputFilters, err = cmd.Flags().GetStringSlice("filter") if err != nil { - return types.ImageListOptions{}, err + return nil, err } } digests, err := cmd.Flags().GetBool("digests") if err != nil { - return types.ImageListOptions{}, err + return nil, err } names, err := cmd.Flags().GetBool("names") if err != nil { - return types.ImageListOptions{}, err + return nil, err } - return types.ImageListOptions{ + return &types.ImageListOptions{ GOptions: globalOptions, Quiet: quiet, NoTrunc: noTrunc, @@ -145,7 +146,7 @@ func imagesAction(cmd *cobra.Command, args []string) error { func imagesShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go new file mode 100644 index 00000000000..bc88386ef4c --- /dev/null +++ b/cmd/nerdctl/image/image_list_test.go @@ -0,0 +1,391 @@ +/* + 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 ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestImages(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("pull", "--quiet", testutil.NginxAlpineImage) + }, + SubTests: []*test.Case{ + { + Description: "No params", + 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) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 2, info) + 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) + found := false + for _, line := range lines[1:] { + repo, _ := tab.ReadRow(line, "REPOSITORY") + tag, _ := tab.ReadRow(line, "TAG") + if repo+":"+tag == testutil.CommonImage { + found = true + break + } + } + assert.Assert(t, found, info) + }, + } + }, + }, + { + Description: "With names", + Command: test.Command("images", "--names", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(testutil.CommonImage), + func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 2, info) + tab := tabutil.NewReader("NAME\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE") + err := tab.ParseHeader(lines[0]) + assert.NilError(t, err, info) + found := false + for _, line := range lines[1:] { + name, _ := tab.ReadRow(line, "NAME") + if name == testutil.CommonImage { + found = true + break + } + } + + assert.Assert(t, found, info) + }, + ), + } + }, + }, + { + Description: "CheckCreatedTime", + 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) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 2, info) + createdTimes := lines + slices.Reverse(createdTimes) + assert.Assert(t, slices.IsSorted(createdTimes), info) + }, + } + }, + }, + }, + } + + if runtime.GOOS == "windows" { + testCase.Require = test.Require( + testCase.Require, + nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3524"), + ) + } + + testCase.Run(t) +} + +func TestImagesFilter(t *testing.T) { + nerdtest.Setup() + + 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") + + 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) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", "taggedimage:one-fragment-one") + helpers.Anyhow("rmi", "-f", "taggedimage:two-fragment-two") + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + data.Set("builtImageID", data.Identifier()) + return helpers.Command("build", "-t", data.Identifier(), data.Get("buildCtx")) + }, + Expected: test.Expects(0, nil, nil), + SubTests: []*test.Case{ + { + Description: "label=foo=bar", + Command: test.Command("images", "--filter", "label=foo=bar"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("builtImageID")), + } + }, + }, + { + Description: "label=foo=bar1", + Command: test.Command("images", "--filter", "label=foo=bar1"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.DoesNotContain(data.Get("builtImageID")), + } + }, + }, + { + Description: "label=foo=bar label=version=0.1", + Command: test.Command("images", "--filter", "label=foo=bar", "--filter", "label=version=0.1"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("builtImageID")), + } + }, + }, + { + Description: "label=foo=bar label=version=0.2", + Command: test.Command("images", "--filter", "label=foo=bar", "--filter", "label=version=0.2"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.DoesNotContain(data.Get("builtImageID")), + } + }, + }, + { + Description: "label=version", + Command: test.Command("images", "--filter", "label=version"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("builtImageID")), + } + }, + }, + { + Description: "reference=ID*", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("images", "--filter", fmt.Sprintf("reference=%s*", data.Get("builtImageID"))) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("builtImageID")), + } + }, + }, + { + Description: "reference=tagged*:*fragment*", + Command: test.Command("images", "--filter", "reference=tagged*:*fragment*"), + Expected: test.Expects(0, nil, test.All( + test.Contains("one-"), + test.Contains("two-"), + )), + }, + { + Description: "before=ID:latest", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("images", "--filter", fmt.Sprintf("before=%s:latest", data.Get("builtImageID"))) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(testutil.ImageRepo(testutil.CommonImage)), + test.DoesNotContain(data.Get("builtImageID")), + ), + } + }, + }, + { + Description: "since=" + testutil.CommonImage, + Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage)), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("builtImageID")), + test.DoesNotContain(testutil.ImageRepo(testutil.CommonImage)), + ), + } + }, + }, + { + Description: "since=" + testutil.CommonImage + " " + testutil.CommonImage, + Command: test.Command("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage), testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain(data.Get("builtImageID")), + test.DoesNotContain(testutil.ImageRepo(testutil.CommonImage)), + ), + } + }, + }, + { + 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(-1, []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(-1, []error{errors.New("No such image: ")}, nil), + }, + }, + } + + testCase.Run(t) +} + +func TestImagesFilterDangling(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "TestImagesFilterDangling", + // This test relies on a clean slate and the ability to GC everything + NoParallel: true, + Require: nerdtest.Build, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-notag-string"] + `, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("container", "prune", "-f") + helpers.Anyhow("image", "prune", "--all", "-f") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx")) + }, + Expected: test.Expects(0, nil, nil), + SubTests: []*test.Case{ + { + Description: "dangling", + Command: test.Command("images", "--filter", "dangling=true"), + Expected: test.Expects(0, nil, test.Contains("")), + }, + { + Description: "not dangling", + Command: test.Command("images", "--filter", "dangling=false"), + Expected: test.Expects(0, nil, test.DoesNotContain("")), + }, + }, + } + + testCase.Run(t) +} + +func TestImagesKubeWithKubeHideDupe(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.OnlyKubernetes, + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.BusyboxImage) + }, + SubTests: []*test.Case{ + { + Description: "The same imageID will not print no-repo:tag in k8s.io with kube-hide-dupe", + 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) { + var imageID string + var skipLine int + lines := strings.Split(strings.TrimSpace(stdout), "\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) + found := true + for i, line := range lines[1:] { + repo, _ := tab.ReadRow(line, "REPOSITORY") + tag, _ := tab.ReadRow(line, "TAG") + if repo+":"+tag == testutil.BusyboxImage { + skipLine = i + imageID, _ = tab.ReadRow(line, "IMAGE ID") + break + } + } + for i, line := range lines[1:] { + if i == skipLine { + continue + } + id, _ := tab.ReadRow(line, "IMAGE ID") + if id == imageID { + found = false + break + } + } + assert.Assert(t, found, info) + }, + } + }, + }, + { + Description: "the same imageId will print no-repo:tag in k8s.io without kube-hide-dupe", + Command: test.Command("images"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(""), + } + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_load.go b/cmd/nerdctl/image/image_load.go similarity index 77% rename from cmd/nerdctl/image_load.go rename to cmd/nerdctl/image/image_load.go index 5d3f31063ce..3ff8b18a892 100644 --- a/cmd/nerdctl/image_load.go +++ b/cmd/nerdctl/image/image_load.go @@ -14,16 +14,19 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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/imgutil/load" ) -func newLoadCommand() *cobra.Command { +func NewLoadCommand() *cobra.Command { var loadCommand = &cobra.Command{ Use: "load", Args: cobra.NoArgs, @@ -35,11 +38,12 @@ func newLoadCommand() *cobra.Command { } loadCommand.Flags().StringP("input", "i", "", "Read from tar archive file, instead of STDIN") + loadCommand.Flags().BoolP("quiet", "q", false, "Suppress the load output") // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" loadCommand.Flags().StringSlice("platform", []string{}, "Import content for a specific platform") - loadCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + loadCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) loadCommand.Flags().Bool("all-platforms", false, "Import content for all platforms") // #endregion @@ -51,7 +55,7 @@ func processLoadCommandFlags(cmd *cobra.Command) (types.ImageLoadOptions, error) if err != nil { return types.ImageLoadOptions{}, err } - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageLoadOptions{}, err } @@ -63,6 +67,10 @@ func processLoadCommandFlags(cmd *cobra.Command) (types.ImageLoadOptions, error) if err != nil { return types.ImageLoadOptions{}, err } + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return types.ImageLoadOptions{}, err + } return types.ImageLoadOptions{ GOptions: globalOptions, Input: input, @@ -70,6 +78,7 @@ func processLoadCommandFlags(cmd *cobra.Command) (types.ImageLoadOptions, error) AllPlatforms: allPlatforms, Stdout: cmd.OutOrStdout(), Stdin: cmd.InOrStdin(), + Quiet: quiet, }, nil } @@ -85,5 +94,6 @@ func loadAction(cmd *cobra.Command, _ []string) error { } defer cancel() - return image.Load(ctx, client, options) + _, err = load.FromArchive(ctx, client, options) + return err } diff --git a/cmd/nerdctl/image/image_load_test.go b/cmd/nerdctl/image/image_load_test.go new file mode 100644 index 00000000000..4a0e170fa3e --- /dev/null +++ b/cmd/nerdctl/image/image_load_test.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 image + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestLoadStdinFromPipe(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "TestLoadStdinFromPipe", + Require: test.Linux, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + helpers.Ensure("tag", testutil.CommonImage, identifier) + helpers.Ensure("save", identifier, "-o", filepath.Join(data.TempDir(), "common.tar")) + helpers.Ensure("rmi", "-f", identifier) + }, + 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("load") + reader, err := os.Open(filepath.Join(data.TempDir(), "common.tar")) + assert.NilError(t, err, "failed to open common.tar") + cmd.WithStdin(reader) + return cmd + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + return &test.Expected{ + Output: test.All( + test.Contains(fmt.Sprintf("Loaded image: %s:latest", identifier)), + func(stdout string, info string, t *testing.T) { + assert.Assert(t, strings.Contains(helpers.Capture("images"), identifier)) + }, + ), + } + }, + } + + testCase.Run(t) +} + +func TestLoadStdinEmpty(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "TestLoadStdinEmpty", + Require: test.Linux, + Command: test.Command("load"), + Expected: test.Expects(1, nil, nil), + } + + testCase.Run(t) +} + +func TestLoadQuiet(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Description: "TestLoadQuiet", + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("pull", testutil.CommonImage) + helpers.Ensure("tag", testutil.CommonImage, identifier) + helpers.Ensure("save", identifier, "-o", filepath.Join(data.TempDir(), "common.tar")) + helpers.Ensure("rmi", "-f", identifier) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("load", "--quiet", "--input", filepath.Join(data.TempDir(), "common.tar")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(fmt.Sprintf("Loaded image: %s:latest", data.Identifier())), + test.DoesNotContain("Loading layer"), + ), + } + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_prune.go b/cmd/nerdctl/image/image_prune.go similarity index 75% rename from cmd/nerdctl/image_prune.go rename to cmd/nerdctl/image/image_prune.go index f444c9bb8c2..9eefa288958 100644 --- a/cmd/nerdctl/image_prune.go +++ b/cmd/nerdctl/image/image_prune.go @@ -14,16 +14,17 @@ limitations under the License. */ -package main +package image import ( "fmt" - "strings" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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/image" ) func newImagePruneCommand() *cobra.Command { @@ -37,12 +38,13 @@ func newImagePruneCommand() *cobra.Command { } imagePruneCommand.Flags().BoolP("all", "a", false, "Remove all unused images, not just dangling ones") + imagePruneCommand.Flags().StringSlice("filter", []string{}, "Filter output based on conditions provided") imagePruneCommand.Flags().BoolP("force", "f", false, "Do not prompt for confirmation") return imagePruneCommand } func processImagePruneOptions(cmd *cobra.Command) (types.ImagePruneOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImagePruneOptions{}, err } @@ -51,6 +53,14 @@ func processImagePruneOptions(cmd *cobra.Command) (types.ImagePruneOptions, erro return types.ImagePruneOptions{}, err } + var filters []string + if cmd.Flags().Changed("filter") { + filters, err = cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.ImagePruneOptions{}, err + } + } + force, err := cmd.Flags().GetBool("force") if err != nil { return types.ImagePruneOptions{}, err @@ -60,6 +70,7 @@ func processImagePruneOptions(cmd *cobra.Command) (types.ImagePruneOptions, erro Stdout: cmd.OutOrStdout(), GOptions: globalOptions, All: all, + Filters: filters, Force: force, }, err } @@ -71,22 +82,15 @@ func imagePruneAction(cmd *cobra.Command, _ []string) error { } if !options.Force { - var ( - confirm string - msg string - ) + var msg string if !options.All { msg = "This will remove all dangling images." } else { msg = "This will remove all images without at least one container associated to them." } - msg += "\nAre you sure you want to continue? [y/N] " - - fmt.Fprintf(cmd.OutOrStdout(), "WARNING! %s", msg) - fmt.Fscanf(cmd.InOrStdin(), "%s", &confirm) - if strings.ToLower(confirm) != "y" { - return nil + if confirmed, err := helpers.Confirm(cmd, fmt.Sprintf("WARNING! %s.", msg)); err != nil || !confirmed { + return err } } diff --git a/cmd/nerdctl/image/image_prune_test.go b/cmd/nerdctl/image/image_prune_test.go new file mode 100644 index 00000000000..8a04d24a417 --- /dev/null +++ b/cmd/nerdctl/image/image_prune_test.go @@ -0,0 +1,242 @@ +/* + 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" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestImagePrune(t *testing.T) { + testCase := nerdtest.Setup() + + // Cannot use a custom namespace with buildkitd right now, so, no parallel it is + testCase.NoParallel = true + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + // We need to delete everything here for prune to make any sense + imgList := strings.TrimSpace(helpers.Capture("images", "--no-trunc", "-aq")) + if imgList != "" { + helpers.Ensure(append([]string{"rmi", "-f"}, strings.Split(imgList, "\n")...)...) + } + } + testCase.SubTests = []*test.Case{ + { + Description: "without all", + NoParallel: true, + Require: test.Require( + // This never worked with Docker - the only reason we ever got was side effects from other tests + // See inline comments. + test.Not(nerdtest.Docker), + nerdtest.Build, + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "nerdctl-test-image-prune"] + `, testutil.CommonImage) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx) + // After we rebuild with tag, docker will no longer show the version from above + // Swapping order does not change anything. + helpers.Ensure("build", "-t", identifier, buildCtx) + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, ""), "Missing ") + assert.Assert(t, strings.Contains(imgList, identifier), "Missing "+identifier) + }, + Command: test.Command("image", "prune", "--force"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + identifier := data.Identifier() + return &test.Expected{ + Output: test.All( + func(stdout string, info string, t *testing.T) { + assert.Assert(t, !strings.Contains(stdout, identifier), info) + }, + func(stdout string, info string, t *testing.T) { + imgList := helpers.Capture("images") + assert.Assert(t, !strings.Contains(imgList, ""), imgList) + assert.Assert(t, strings.Contains(imgList, identifier), info) + }, + ), + } + }, + }, + { + Description: "with all", + Require: test.Require( + // Same as above + test.Not(nerdtest.Docker), + nerdtest.Build, + ), + // Cannot use a custom namespace with buildkitd right now, so, no parallel it is + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("rmi", "-f", identifier) + helpers.Anyhow("rm", "-f", identifier) + }, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + dockerfile := fmt.Sprintf(`FROM %s + CMD ["echo", "nerdctl-test-image-prune"] + `, testutil.CommonImage) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", buildCtx) + helpers.Ensure("build", "-t", identifier, buildCtx) + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, ""), "Missing ") + assert.Assert(t, strings.Contains(imgList, identifier), "Missing "+identifier) + helpers.Ensure("run", "--name", identifier, identifier) + }, + Command: test.Command("image", "prune", "--force", "--all"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + func(stdout string, info string, t *testing.T) { + assert.Assert(t, !strings.Contains(stdout, data.Identifier()), info) + }, + func(stdout string, info string, t *testing.T) { + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, data.Identifier()), info) + 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) + imgList = helpers.Capture("images") + assert.Assert(t, !strings.Contains(imgList, data.Identifier()), info) + }, + ), + } + }, + }, + { + Description: "with filter label", + Require: nerdtest.Build, + // Cannot use a custom namespace with buildkitd right now, so, no parallel it is + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-test-image-prune-filter-label"] +LABEL foo=bar +LABEL version=0.1`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", data.Identifier(), buildCtx) + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, data.Identifier()), "Missing "+data.Identifier()) + }, + Command: test.Command("image", "prune", "--force", "--all", "--filter", "label=foo=baz"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + func(stdout string, info string, t *testing.T) { + assert.Assert(t, !strings.Contains(stdout, data.Identifier()), info) + }, + func(stdout string, info string, t *testing.T) { + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, data.Identifier()), info) + }, + func(stdout string, info string, t *testing.T) { + prune := helpers.Capture("image", "prune", "--force", "--all", "--filter", "label=foo=bar") + assert.Assert(t, strings.Contains(prune, data.Identifier()), info) + imgList := helpers.Capture("images") + assert.Assert(t, !strings.Contains(imgList, data.Identifier()), info) + }, + ), + } + }, + }, + { + Description: "with until", + Require: nerdtest.Build, + // Cannot use a custom namespace with buildkitd right now, so, no parallel it is + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +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.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", data.Identifier(), buildCtx) + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, data.Identifier()), "Missing "+data.Identifier()) + data.Set("imageID", data.Identifier()) + }, + Command: test.Command("image", "prune", "--force", "--all", "--filter", "until=12h"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain(data.Get("imageID")), + func(stdout string, info string, t *testing.T) { + imgList := helpers.Capture("images") + assert.Assert(t, strings.Contains(imgList, data.Get("imageID")), info) + }, + ), + } + }, + SubTests: []*test.Case{ + { + Description: "Wait and remove until=10ms", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + time.Sleep(1 * time.Second) + }, + Command: test.Command("image", "prune", "--force", "--all", "--filter", "until=10ms"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("imageID")), + func(stdout string, info string, t *testing.T) { + imgList := helpers.Capture("images") + assert.Assert(t, !strings.Contains(imgList, data.Get("imageID")), imgList, info) + }, + ), + } + }, + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_pull.go b/cmd/nerdctl/image/image_pull.go similarity index 74% rename from cmd/nerdctl/image_pull.go rename to cmd/nerdctl/image/image_pull.go index 71d00899d23..cedef1af639 100644 --- a/cmd/nerdctl/image_pull.go +++ b/cmd/nerdctl/image/image_pull.go @@ -14,20 +14,25 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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" + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) -func newPullCommand() *cobra.Command { +func NewPullCommand() *cobra.Command { var pullCommand = &cobra.Command{ Use: "pull [flags] NAME[:TAG]", Short: "Pull an image from a registry. Optionally specify \"ipfs://\" or \"ipns://\" scheme to pull image from IPFS.", - Args: IsExactArgs(1), + Args: helpers.IsExactArgs(1), RunE: pullAction, SilenceUsage: true, SilenceErrors: true, @@ -40,7 +45,7 @@ func newPullCommand() *cobra.Command { // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" pullCommand.Flags().StringSlice("platform", nil, "Pull content for a specific platform") - pullCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + pullCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) pullCommand.Flags().Bool("all-platforms", false, "Pull content for all platforms") // #endregion @@ -56,6 +61,10 @@ func newPullCommand() *cobra.Command { pullCommand.Flags().String("cosign-certificate-oidc-issuer-regexp", "", "A regular expression alternative to --certificate-oidc-issuer for --verify=cosign,. Accepts the Go regular expression syntax described at https://golang.org/s/re2syntax. Either --cosign-certificate-oidc-issuer or --cosign-certificate-oidc-issuer-regexp must be set for keyless flows") // #endregion + // #region socipull flags + pullCommand.Flags().String("soci-index-digest", "", "Specify a particular index digest for SOCI. If left empty, SOCI will automatically use the index determined by the selection policy.") + // #endregion + pullCommand.Flags().BoolP("quiet", "q", false, "Suppress verbose output") pullCommand.Flags().String("ipfs-address", "", "multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs)") @@ -64,7 +73,7 @@ func newPullCommand() *cobra.Command { } func processPullCommandFlags(cmd *cobra.Command) (types.ImagePullOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImagePullOptions{}, err } @@ -77,10 +86,20 @@ func processPullCommandFlags(cmd *cobra.Command) (types.ImagePullOptions, error) return types.ImagePullOptions{}, err } + ociSpecPlatform, err := platformutil.NewOCISpecPlatformSlice(allPlatforms, platform) + if err != nil { + return types.ImagePullOptions{}, err + } + unpackStr, err := cmd.Flags().GetString("unpack") if err != nil { return types.ImagePullOptions{}, err } + unpack, err := strutil.ParseBoolOrAuto(unpackStr) + if err != nil { + return types.ImagePullOptions{}, err + } + quiet, err := cmd.Flags().GetBool("quiet") if err != nil { return types.ImagePullOptions{}, err @@ -89,20 +108,30 @@ func processPullCommandFlags(cmd *cobra.Command) (types.ImagePullOptions, error) if err != nil { return types.ImagePullOptions{}, err } - verifyOptions, err := processImageVerifyOptions(cmd) + + sociIndexDigest, err := cmd.Flags().GetString("soci-index-digest") + if err != nil { + return types.ImagePullOptions{}, err + } + + verifyOptions, err := helpers.ProcessImageVerifyOptions(cmd) if err != nil { return types.ImagePullOptions{}, err } return types.ImagePullOptions{ - GOptions: globalOptions, - VerifyOptions: verifyOptions, - AllPlatforms: allPlatforms, - Platform: platform, - Unpack: unpackStr, - Quiet: quiet, - IPFSAddress: ipfsAddressStr, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.OutOrStderr(), + GOptions: globalOptions, + VerifyOptions: verifyOptions, + OCISpecPlatform: ociSpecPlatform, + Unpack: unpack, + Mode: "always", + Quiet: quiet, + IPFSAddress: ipfsAddressStr, + RFlags: types.RemoteSnapshotterFlags{ + SociIndexDigest: sociIndexDigest, + }, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.OutOrStderr(), + ProgressOutputToStdout: true, }, nil } diff --git a/cmd/nerdctl/image/image_pull_linux_test.go b/cmd/nerdctl/image/image_pull_linux_test.go new file mode 100644 index 00000000000..c350d1e71a4 --- /dev/null +++ b/cmd/nerdctl/image/image_pull_linux_test.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 image + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "gotest.tools/v3/assert" + + testhelpers "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" +) + +func TestImagePullWithCosign(t *testing.T) { + nerdtest.Setup() + + var registry *testregistry.RegistryServer + var keyPair *testhelpers.CosignKeyPair + + testCase := &test.Case{ + Require: test.Require( + test.Linux, + nerdtest.Build, + test.Binary("cosign"), + test.Not(nerdtest.Docker), + ), + Env: map[string]string{ + "COSIGN_PASSWORD": "1", + }, + Setup: func(data test.Data, helpers test.Helpers) { + keyPair = testhelpers.NewCosignKeyPair(t, "cosign-key-pair", "1") + base := testutil.NewBase(t) + registry = testregistry.NewWithNoAuth(base, 0, false) + testImageRef := fmt.Sprintf("%s:%d/%s", "127.0.0.1", registry.Port, data.Identifier()) + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", testImageRef+":one", buildCtx) + helpers.Ensure("build", "-t", testImageRef+":two", buildCtx) + helpers.Ensure("push", "--sign=cosign", "--cosign-key="+keyPair.PrivateKey, testImageRef+":one") + helpers.Ensure("push", "--sign=cosign", "--cosign-key="+keyPair.PrivateKey, testImageRef+":two") + helpers.Ensure("rmi", "-f", testImageRef) + data.Set("imageref", testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if keyPair != nil { + keyPair.Cleanup() + } + if registry != nil { + registry.Cleanup(nil) + testImageRef := fmt.Sprintf("%s:%d/%s", "127.0.0.1", registry.Port, data.Identifier()) + helpers.Anyhow("rmi", "-f", testImageRef+":one") + helpers.Anyhow("rmi", "-f", testImageRef+":two") + } + }, + SubTests: []*test.Case{ + { + Description: "Pull with the correct key", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("pull", "--verify=cosign", "--cosign-key="+keyPair.PublicKey, data.Get("imageref")+":one") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "Pull with unrelated key", + Env: map[string]string{ + "COSIGN_PASSWORD": "2", + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + newKeyPair := testhelpers.NewCosignKeyPair(t, "cosign-key-pair-test", "2") + return helpers.Command("pull", "--verify=cosign", "--cosign-key="+newKeyPair.PublicKey, data.Get("imageref")+":two") + }, + Expected: test.Expects(12, nil, nil), + }, + }, + } + + testCase.Run(t) +} + +func TestImagePullPlainHttpWithDefaultPort(t *testing.T) { + nerdtest.Setup() + + var registry *testregistry.RegistryServer + + testCase := &test.Case{ + Require: test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.Build, + ), + Setup: func(data test.Data, helpers test.Helpers) { + base := testutil.NewBase(t) + registry = testregistry.NewWithNoAuth(base, 80, false) + testImageRef := fmt.Sprintf("%s/%s:%s", + registry.IP.String(), data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, testutil.CommonImage) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + helpers.Ensure("build", "-t", testImageRef, buildCtx) + helpers.Ensure("--insecure-registry", "push", testImageRef) + helpers.Ensure("rmi", "-f", testImageRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + testImageRef := fmt.Sprintf("%s/%s:%s", + registry.IP.String(), data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + return helpers.Command("--insecure-registry", "pull", testImageRef) + }, + Expected: test.Expects(0, nil, nil), + Cleanup: func(data test.Data, helpers test.Helpers) { + if registry != nil { + registry.Cleanup(nil) + testImageRef := fmt.Sprintf("%s/%s:%s", + registry.IP.String(), data.Identifier(), strings.Split(testutil.CommonImage, ":")[1]) + helpers.Anyhow("rmi", "-f", testImageRef) + } + }, + } + + testCase.Run(t) +} + +func TestImagePullSoci(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.Soci, + ), + + // NOTE: these tests cannot be run in parallel, as they depend on the output of host `mount` + // They also feel prone to raciness... + SubTests: []*test.Case{ + { + Description: "Run without specifying SOCI index", + NoParallel: true, + Data: test. + WithData("remoteSnapshotsExpectedCount", "11"). + Set("sociIndexDigest", ""), + 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) { + data.Set("remoteSnapshotsInitialCount", strconv.Itoa(strings.Count(stdout, "fuse.rawBridge"))) + }, + }) + helpers.Ensure("--snapshotter=soci", "pull", testutil.FfmpegSociImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", testutil.FfmpegSociImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("mount") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + remoteSnapshotsInitialCount, _ := strconv.Atoi(data.Get("remoteSnapshotsInitialCount")) + remoteSnapshotsActualCount := strings.Count(stdout, "fuse.rawBridge") + assert.Equal(t, + data.Get("remoteSnapshotsExpectedCount"), + strconv.Itoa(remoteSnapshotsActualCount-remoteSnapshotsInitialCount), + info) + }, + } + }, + }, + { + Description: "Run with bad SOCI index", + NoParallel: true, + Data: test. + WithData("remoteSnapshotsExpectedCount", "11"). + Set("sociIndexDigest", "sha256:thisisabadindex0000000000000000000000000000000000000000000000000"), + 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) { + data.Set("remoteSnapshotsInitialCount", strconv.Itoa(strings.Count(stdout, "fuse.rawBridge"))) + }, + }) + helpers.Ensure("--snapshotter=soci", "pull", testutil.FfmpegSociImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", testutil.FfmpegSociImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("mount") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + remoteSnapshotsInitialCount, _ := strconv.Atoi(data.Get("remoteSnapshotsInitialCount")) + remoteSnapshotsActualCount := strings.Count(stdout, "fuse.rawBridge") + assert.Equal(t, + data.Get("remoteSnapshotsExpectedCount"), + strconv.Itoa(remoteSnapshotsActualCount-remoteSnapshotsInitialCount), + info) + }, + } + }, + }, + }, + } + + testCase.Run(t) +} + +func TestImagePullProcessOutput(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + SubTests: []*test.Case{ + { + Description: "Pull Image - output should be in stdout", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", testutil.BusyboxImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("pull", testutil.BusyboxImage) + }, + Expected: test.Expects(0, nil, test.Contains(testutil.BusyboxImage)), + }, + { + Description: "Run Container with image pull - output should be in stderr", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", testutil.BusyboxImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", testutil.BusyboxImage) + }, + Expected: test.Expects(0, nil, test.DoesNotContain(testutil.BusyboxImage)), + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_push.go b/cmd/nerdctl/image/image_push.go similarity index 73% rename from cmd/nerdctl/image_push.go rename to cmd/nerdctl/image/image_push.go index b217d281a97..eebadbf7586 100644 --- a/cmd/nerdctl/image_push.go +++ b/cmd/nerdctl/image/image_push.go @@ -14,24 +14,27 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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" ) const ( allowNonDistFlag = "allow-nondistributable-artifacts" ) -func newPushCommand() *cobra.Command { +func NewPushCommand() *cobra.Command { var pushCommand = &cobra.Command{ Use: "push [flags] NAME[:TAG]", Short: "Push an image or a repository to a registry. Optionally specify \"ipfs://\" or \"ipns://\" scheme to push image to IPFS.", - Args: IsExactArgs(1), + Args: helpers.IsExactArgs(1), RunE: pushAction, ValidArgsFunction: pushShellComplete, SilenceUsage: true, @@ -40,7 +43,7 @@ func newPushCommand() *cobra.Command { // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" pushCommand.Flags().StringSlice("platform", []string{}, "Push content for a specific platform") - pushCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + pushCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) pushCommand.Flags().Bool("all-platforms", false, "Push content for all platforms") // #endregion @@ -57,6 +60,11 @@ func newPushCommand() *cobra.Command { pushCommand.Flags().String("notation-key-name", "", "Signing key name for a key previously added to notation's key list for --sign=notation") // #endregion + // #region soci flags + pushCommand.Flags().Int64("soci-span-size", -1, "Span size that soci index uses to segment layer data. Default is 4 MiB.") + pushCommand.Flags().Int64("soci-min-layer-size", -1, "Minimum layer size to build zTOC for. Smaller layers won't have zTOC and not lazy pulled. Default is 10 MiB.") + // #endregion + pushCommand.Flags().BoolP("quiet", "q", false, "Suppress verbose output") pushCommand.Flags().Bool(allowNonDistFlag, false, "Allow pushing images with non-distributable blobs") @@ -65,7 +73,7 @@ func newPushCommand() *cobra.Command { } func processImagePushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImagePushOptions{}, err } @@ -101,9 +109,14 @@ func processImagePushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) if err != nil { return types.ImagePushOptions{}, err } + sociOptions, err := processSociOptions(cmd) + if err != nil { + return types.ImagePushOptions{}, err + } return types.ImagePushOptions{ GOptions: globalOptions, SignOptions: signOptions, + SociOptions: sociOptions, Platforms: platform, AllPlatforms: allPlatforms, Estargz: estargz, @@ -133,5 +146,28 @@ func pushAction(cmd *cobra.Command, args []string) error { func pushShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) +} + +func processImageSignOptions(cmd *cobra.Command) (opt types.ImageSignOptions, err error) { + if opt.Provider, err = cmd.Flags().GetString("sign"); err != nil { + return + } + if opt.CosignKey, err = cmd.Flags().GetString("cosign-key"); err != nil { + return + } + if opt.NotationKeyName, err = cmd.Flags().GetString("notation-key-name"); err != nil { + return + } + return +} + +func processSociOptions(cmd *cobra.Command) (opt types.SociOptions, err error) { + if opt.SpanSize, err = cmd.Flags().GetInt64("soci-span-size"); err != nil { + return + } + if opt.MinLayerSize, err = cmd.Flags().GetInt64("soci-min-layer-size"); err != nil { + return + } + return } diff --git a/cmd/nerdctl/image/image_push_linux_test.go b/cmd/nerdctl/image/image_push_linux_test.go new file mode 100644 index 00000000000..791141876ee --- /dev/null +++ b/cmd/nerdctl/image/image_push_linux_test.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 image + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" +) + +func TestPush(t *testing.T) { + nerdtest.Setup() + + var registryNoAuthHTTPRandom, registryNoAuthHTTPDefault, registryTokenAuthHTTPSRandom *testregistry.RegistryServer + + testCase := &test.Case{ + Require: test.Linux, + + 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) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + if registryNoAuthHTTPRandom != nil { + registryNoAuthHTTPRandom.Cleanup(nil) + } + if registryNoAuthHTTPDefault != nil { + registryNoAuthHTTPDefault.Cleanup(nil) + } + if registryTokenAuthHTTPSRandom != nil { + registryTokenAuthHTTPSRandom.Cleanup(nil) + } + }, + + SubTests: []*test.Case{ + { + 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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", data.Get("testImageRef")) + }, + Expected: test.Expects(1, []error{errors.New("server gave HTTP response to HTTPS client")}, nil), + }, + { + Description: "plain http with insecure", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--insecure-registry", data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + 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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "plain http with insecure, default port", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--insecure-registry", data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with insecure, with login", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + helpers.Ensure("--insecure-registry", "login", "-u", "admin", "-p", "badmin", + fmt.Sprintf("%s:%d", registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port)) + + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--insecure-registry", data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with hosts dir, with login", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.CommonImage, testImageRef) + helpers.Ensure("--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, "login", "-u", "admin", "-p", "badmin", + fmt.Sprintf("%s:%d", registryTokenAuthHTTPSRandom.IP.String(), registryTokenAuthHTTPSRandom.Port)) + + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--hosts-dir", registryTokenAuthHTTPSRandom.HostsDir, data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "non distributable artifacts", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.NonDistBlobImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--insecure-registry", data.Get("testImageRef")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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() + } + assert.Equal(t, resp.StatusCode, http.StatusNotFound, "non-distributable blob should not be available") + }, + } + }, + }, + { + Description: "non distributable artifacts (with)", + Require: test.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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.NonDistBlobImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--insecure-registry", "--allow-nondistributable-artifacts", data.Get("testImageRef")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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() + } + assert.Equal(t, resp.StatusCode, http.StatusOK, "non-distributable blob should be available") + }, + } + }, + }, + { + Description: "soci", + Require: test.Require( + nerdtest.Soci, + test.Not(nerdtest.Docker), + ), + 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]) + data.Set("testImageRef", testImageRef) + helpers.Ensure("tag", testutil.UbuntuImage, testImageRef) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("testImageRef") != "" { + helpers.Anyhow("rmi", "-f", data.Get("testImageRef")) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", "--snapshotter=soci", "--insecure-registry", "--soci-span-size=2097152", "--soci-min-layer-size=20971520", data.Get("testImageRef")) + }, + Expected: test.Expects(0, nil, nil), + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_remove.go b/cmd/nerdctl/image/image_remove.go similarity index 84% rename from cmd/nerdctl/image_remove.go rename to cmd/nerdctl/image/image_remove.go index b060ec27a9e..cb9a86c39d5 100644 --- a/cmd/nerdctl/image_remove.go +++ b/cmd/nerdctl/image/image_remove.go @@ -14,16 +14,19 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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 newRmiCommand() *cobra.Command { +func NewRmiCommand() *cobra.Command { var rmiCommand = &cobra.Command{ Use: "rmi [flags] IMAGE [IMAGE, ...]", Short: "Remove one or more images", @@ -40,7 +43,7 @@ func newRmiCommand() *cobra.Command { } func processImageRemoveOptions(cmd *cobra.Command) (types.ImageRemoveOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageRemoveOptions{}, err } @@ -79,5 +82,5 @@ func rmiAction(cmd *cobra.Command, args []string) error { func rmiShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image/image_remove_test.go b/cmd/nerdctl/image/image_remove_test.go new file mode 100644 index 00000000000..e7e0e4d2c92 --- /dev/null +++ b/cmd/nerdctl/image/image_remove_test.go @@ -0,0 +1,523 @@ +/* + 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 ( + "errors" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestRemove(t *testing.T) { + testCase := nerdtest.Setup() + + const ( + imgShortIDKey = "imgShortID" + ) + + repoName, _ := imgutil.ParseRepoTag(testutil.CommonImage) + nginxRepoName, _ := imgutil.ParseRepoTag(testutil.NginxAlpineImage) + // NOTES: + // - since all of these are rmi-ing the common image, we need private mode + testCase.Require = nerdtest.Private + + testCase.SubTests = []*test.Case{ + { + Description: "Remove image with stopped container - without -f", + NoParallel: true, + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "--name", data.Identifier(), testutil.CommonImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("rmi", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("image is being used")}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with stopped container - with -f", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "--name", data.Identifier(), testutil.CommonImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + 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) { + helpers.Command("images").Run(&test.Expected{ + Output: test.DoesNotContain(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with running container - without -f", + NoParallel: true, + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("rmi", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("image is being used")}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with running container - with -f", + NoParallel: true, + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + + img := nerdtest.InspectImage(helpers, testutil.CommonImage) + repoName, _ := imgutil.ParseRepoTag(testutil.CommonImage) + imgShortID := strings.TrimPrefix(img.RepoDigests[0], repoName+"@sha256:")[0:8] + + data.Set(imgShortIDKey, imgShortID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Get(imgShortIDKey)) + }, + Command: test.Command("rmi", "-f", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(""), + }) + }, + } + }, + }, + { + Description: "Remove image with created container - without -f", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("create", "--pull", "always", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("rmi", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("image is being used")}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with created container - with -f", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.NginxAlpineImage) + helpers.Ensure("create", "--pull", "always", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("rmi", testutil.NginxAlpineImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + 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) { + helpers.Command("images").Run(&test.Expected{ + Output: test.All( + test.DoesNotContain(repoName), + // a created container with removed image doesn't impact other `rmi` command + test.DoesNotContain(nginxRepoName), + ), + }) + }, + } + }, + }, + { + Description: "Remove image with paused container - without -f", + NoParallel: true, + Require: test.Require( + test.Not(nerdtest.Docker), + nerdtest.CGroup, + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("pause", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("rmi", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("image is being used")}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with paused container - with -f", + NoParallel: true, + Require: test.Require( + nerdtest.CGroup, + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("pause", data.Identifier()) + + img := nerdtest.InspectImage(helpers, testutil.CommonImage) + repoName, _ := imgutil.ParseRepoTag(testutil.CommonImage) + imgShortID := strings.TrimPrefix(img.RepoDigests[0], repoName+"@sha256:")[0:8] + + data.Set(imgShortIDKey, imgShortID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Get(imgShortIDKey)) + }, + Command: test.Command("rmi", "-f", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(""), + }) + }, + } + }, + }, + { + Description: "Remove image with killed container - without -f", + NoParallel: true, + Require: test.Require( + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("kill", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("rmi", testutil.CommonImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("image is being used")}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images").Run(&test.Expected{ + Output: test.Contains(repoName), + }) + }, + } + }, + }, + { + Description: "Remove image with killed container - with -f", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "--pull", "always", "-d", "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("kill", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + 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) { + helpers.Command("images").Run(&test.Expected{ + Output: test.DoesNotContain(repoName), + }) + }, + } + }, + }, + } + + testCase.Run(t) +} + +// TestIssue3016 tests https://github.com/containerd/nerdctl/issues/3016 +func TestIssue3016(t *testing.T) { + testCase := nerdtest.Setup() + + const ( + tagIDKey = "tagID" + ) + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #3016 - Tags created using the short digest ids of container images cannot be deleted using the nerdctl rmi command.", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.CommonImage) + helpers.Ensure("pull", testutil.NginxAlpineImage) + + img := nerdtest.InspectImage(helpers, testutil.NginxAlpineImage) + repoName, _ := imgutil.ParseRepoTag(testutil.NginxAlpineImage) + tagID := strings.TrimPrefix(img.RepoDigests[0], repoName+"@sha256:")[0:8] + + helpers.Ensure("tag", testutil.CommonImage, tagID) + + data.Set(tagIDKey, tagID) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("rmi", data.Get(tagIDKey)) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("images", data.Get(tagIDKey)).Run(&test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + assert.Equal(t, len(strings.Split(stdout, "\n")), 2) + }, + }) + }, + } + }, + }, + } + + testCase.Run(t) +} + +func TestRemoveKubeWithKubeHideDupe(t *testing.T) { + var numTags, numNoTags int + testCase := nerdtest.Setup() + testCase.NoParallel = true + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--kube-hide-dupe", "rmi", "-f", testutil.BusyboxImage) + } + testCase.Setup = func(data test.Data, helpers test.Helpers) { + numTags = len(strings.Split(strings.TrimSpace(helpers.Capture("--kube-hide-dupe", "images")), "\n")) + numNoTags = len(strings.Split(strings.TrimSpace(helpers.Capture("images")), "\n")) + } + testCase.Require = test.Require( + nerdtest.OnlyKubernetes, + ) + testCase.SubTests = []*test.Case{ + { + Description: "After removing the tag without kube-hide-dupe, repodigest is shown as ", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.BusyboxImage) + }, + Command: test.Command("rmi", "-f", testutil.BusyboxImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags+1, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags+1, info) + }, + }) + }, + } + }, + }, + { + Description: "If there are other tags, the Repodigest will not be deleted", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("--kube-hide-dupe", "rmi", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.BusyboxImage) + helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier()) + }, + Command: test.Command("--kube-hide-dupe", "rmi", testutil.BusyboxImage), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags+1, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags+2, info) + }, + }) + }, + } + }, + }, + { + Description: "After deleting all repo:tag entries, all repodigests will be cleaned up", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.BusyboxImage) + helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + helpers.Ensure("--kube-hide-dupe", "rmi", "-f", testutil.BusyboxImage) + return helpers.Command("--kube-hide-dupe", "rmi", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + helpers.Command("--kube-hide-dupe", "images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numTags, info) + }, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags, info) + }, + }) + }, + } + }, + }, + { + Description: "Test multiple IDs found with provided prefix and force with shortID", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.BusyboxImage) + helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--kube-hide-dupe", "images", testutil.BusyboxImage, "-q") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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: ")}, + }) + helpers.Command("--kube-hide-dupe", "rmi", "--force", stdout[0:12]).Run(&test.Expected{ + ExitCode: 0, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags, info) + }, + }) + }, + } + }, + }, + { + Description: "Test remove image with digestID", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", testutil.BusyboxImage) + helpers.Ensure("tag", testutil.BusyboxImage, data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--kube-hide-dupe", "images", testutil.BusyboxImage, "-q", "--no-trunc") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + imgID := strings.Split(stdout, "\n") + helpers.Command("--kube-hide-dupe", "rmi", imgID[0]).Run(&test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("multiple IDs found with provided prefix: ")}, + }) + helpers.Command("--kube-hide-dupe", "rmi", "--force", imgID[0]).Run(&test.Expected{ + ExitCode: 0, + }) + helpers.Command("images").Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) == numNoTags, info) + }, + }) + }, + } + }, + }, + } + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_save.go b/cmd/nerdctl/image/image_save.go similarity index 86% rename from cmd/nerdctl/image_save.go rename to cmd/nerdctl/image/image_save.go index 3d9e9ca91cd..8c2ce38d309 100644 --- a/cmd/nerdctl/image_save.go +++ b/cmd/nerdctl/image/image_save.go @@ -14,20 +14,23 @@ limitations under the License. */ -package main +package image import ( "fmt" "os" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" "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/image" ) -func newSaveCommand() *cobra.Command { +func NewSaveCommand() *cobra.Command { var saveCommand = &cobra.Command{ Use: "save", Args: cobra.MinimumNArgs(1), @@ -43,7 +46,7 @@ func newSaveCommand() *cobra.Command { // #region platform flags // platform is defined as StringSlice, not StringArray, to allow specifying "--platform=amd64,arm64" saveCommand.Flags().StringSlice("platform", []string{}, "Export content for a specific platform") - saveCommand.RegisterFlagCompletionFunc("platform", shellCompletePlatforms) + saveCommand.RegisterFlagCompletionFunc("platform", completion.Platforms) saveCommand.Flags().Bool("all-platforms", false, "Export content for all platforms") // #endregion @@ -51,7 +54,7 @@ func newSaveCommand() *cobra.Command { } func processImageSaveOptions(cmd *cobra.Command) (types.ImageSaveOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.ImageSaveOptions{}, err } @@ -108,5 +111,5 @@ func saveAction(cmd *cobra.Command, args []string) error { func saveShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } diff --git a/cmd/nerdctl/image/image_save_test.go b/cmd/nerdctl/image/image_save_test.go new file mode 100644 index 00000000000..9e2dae5cda0 --- /dev/null +++ b/cmd/nerdctl/image/image_save_test.go @@ -0,0 +1,197 @@ +/* + 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 ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gotest.tools/v3/assert" + + testhelpers "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestSaveContent(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + // FIXME: move to busybox for windows? + Require: test.Not(test.Windows), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("save", "-o", filepath.Join(data.TempDir(), "out.tar"), testutil.CommonImage) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + rootfsPath := filepath.Join(data.TempDir(), "rootfs") + err := testhelpers.ExtractDockerArchive(filepath.Join(data.TempDir(), "out.tar"), rootfsPath) + assert.NilError(t, err) + etcOSReleasePath := filepath.Join(rootfsPath, "/etc/os-release") + etcOSReleaseBytes, err := os.ReadFile(etcOSReleasePath) + assert.NilError(t, err) + etcOSRelease := string(etcOSReleaseBytes) + assert.Assert(t, strings.Contains(etcOSRelease, "Alpine")) + }, + } + }, + } + + testCase.Run(t) +} + +func TestSave(t *testing.T) { + testCase := nerdtest.Setup() + + // This test relies on the fact that we can remove the common image, which definitely conflicts with others, + // hence the private mode. + // Further note though, that this will hide the fact this the save command could fail if some layers are missing. + // See https://github.com/containerd/nerdctl/issues/3425 and others for details. + testCase.Require = nerdtest.Private + + if runtime.GOOS == "windows" { + testCase.Require = nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3524") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Single image, by id", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("id") != "" { + helpers.Anyhow("rmi", "-f", data.Get("id")) + } + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + img := nerdtest.InspectImage(helpers, testutil.CommonImage) + var id string + // Docker and Nerdctl do not agree on what is the definition of an image ID + if nerdtest.IsDocker() { + id = img.ID + } else { + id = strings.Split(img.RepoDigests[0], ":")[1] + } + tarPath := filepath.Join(data.TempDir(), "out.tar") + helpers.Ensure("save", "-o", tarPath, id) + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Ensure("load", "-i", tarPath) + data.Set("id", id) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get("id"), "sh", "-euxc", "echo foo") + }, + Expected: test.Expects(0, nil, test.Equals("foo\n")), + }, + { + Description: "Image with different names, by id", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("id") != "" { + helpers.Anyhow("rmi", "-f", data.Get("id")) + } + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + img := nerdtest.InspectImage(helpers, testutil.CommonImage) + var id string + if nerdtest.IsDocker() { + id = img.ID + } else { + id = strings.Split(img.RepoDigests[0], ":")[1] + } + helpers.Ensure("tag", testutil.CommonImage, data.Identifier()) + tarPath := filepath.Join(data.TempDir(), "out.tar") + helpers.Ensure("save", "-o", tarPath, id) + helpers.Ensure("rmi", "-f", testutil.CommonImage) + helpers.Ensure("load", "-i", tarPath) + data.Set("id", id) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get("id"), "sh", "-euxc", "echo foo") + }, + Expected: test.Expects(0, nil, test.Equals("foo\n")), + }, + } + + testCase.Run(t) +} + +// TestSaveMultipleImagesWithSameIDAndLoad tests https://github.com/containerd/nerdctl/issues/3806 +func TestSaveMultipleImagesWithSameIDAndLoad(t *testing.T) { + testCase := nerdtest.Setup() + + // This test relies on the fact that we can remove the common image, which definitely conflicts with others, + // hence the private mode. + // Further note though, that this will hide the fact this the save command could fail if some layers are missing. + // See https://github.com/containerd/nerdctl/issues/3425 and others for details. + testCase.Require = nerdtest.Private + + if runtime.GOOS == "windows" { + testCase.Require = nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3524") + } + + testCase.SubTests = []*test.Case{ + { + Description: "Issue #3568 - Save multiple container images with the same image ID but different image names", + NoParallel: true, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("id") != "" { + helpers.Anyhow("rmi", "-f", data.Get("id")) + } + }, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + img := nerdtest.InspectImage(helpers, testutil.CommonImage) + var id string + if nerdtest.IsDocker() { + id = img.ID + } else { + id = strings.Split(img.RepoDigests[0], ":")[1] + } + helpers.Ensure("tag", testutil.CommonImage, data.Identifier()) + tarPath := filepath.Join(data.TempDir(), "out.tar") + helpers.Ensure("save", "-o", tarPath, testutil.CommonImage, data.Identifier()) + helpers.Ensure("rmi", "-f", id) + helpers.Ensure("load", "-i", tarPath) + data.Set("id", id) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("images", "--no-trunc") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Errors: []error{}, + Output: func(stdout string, info string, t *testing.T) { + assert.Equal(t, strings.Count(stdout, data.Get("id")), 2) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/image_tag.go b/cmd/nerdctl/image/image_tag.go similarity index 77% rename from cmd/nerdctl/image_tag.go rename to cmd/nerdctl/image/image_tag.go index 4361eeffe3b..4e3d5b965fc 100644 --- a/cmd/nerdctl/image_tag.go +++ b/cmd/nerdctl/image/image_tag.go @@ -14,21 +14,23 @@ limitations under the License. */ -package main +package image import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" - "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 newTagCommand() *cobra.Command { +func NewTagCommand() *cobra.Command { var tagCommand = &cobra.Command{ Use: "tag [flags] SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]", Short: "Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE", - Args: IsExactArgs(2), + Args: helpers.IsExactArgs(2), RunE: tagAction, ValidArgsFunction: tagShellComplete, SilenceUsage: true, @@ -38,7 +40,7 @@ func newTagCommand() *cobra.Command { } func tagAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -61,7 +63,7 @@ func tagAction(cmd *cobra.Command, args []string) error { func tagShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) < 2 { // show image names - return shellCompleteImageNames(cmd) + return completion.ImageNames(cmd) } return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/nerdctl/image/image_test.go b/cmd/nerdctl/image/image_test.go new file mode 100644 index 00000000000..4cbba7d968f --- /dev/null +++ b/cmd/nerdctl/image/image_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 image + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/image_convert_linux_test.go b/cmd/nerdctl/image_convert_linux_test.go deleted file mode 100644 index 4b7eff95856..00000000000 --- a/cmd/nerdctl/image_convert_linux_test.go +++ /dev/null @@ -1,77 +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 main - -import ( - "fmt" - "runtime" - "testing" - - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "gotest.tools/v3/icmd" -) - -func TestImageConvertNydus(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no windows support yet") - } - testutil.RequireExecutable(t, "nydus-image") - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - convertedImage := testutil.Identifier(t) + ":nydus" - base.Cmd("rmi", convertedImage).Run() - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("image", "convert", "--nydus", "--oci", - testutil.CommonImage, convertedImage).AssertOK() - defer base.Cmd("rmi", convertedImage).Run() - - // use `nydusify` check whether the convertd nydus image is valid - - // skip if rootless - if rootlessutil.IsRootless() { - t.Skip("Nydusify check is not supported rootless mode.") - } - - // skip if nydusify is not installed - testutil.RequireExecutable(t, "nydusify") - - // setup local docker registry - registryPort := 15000 - registry := testregistry.NewPlainHTTP(base, registryPort) - defer registry.Cleanup() - - remoteImage := fmt.Sprintf("%s:%d/nydusd-image:test", registry.IP.String(), registryPort) - base.Cmd("tag", convertedImage, remoteImage).AssertOK() - defer base.Cmd("rmi", remoteImage).Run() - base.Cmd("push", "--insecure-registry", remoteImage).AssertOK() - nydusifyCmd := testutil.Cmd{ - Cmd: icmd.Command( - "nydusify", - "check", - "--source", - testutil.CommonImage, - "--target", - remoteImage, - "--source-insecure", - "--target-insecure", - ), - Base: base, - } - nydusifyCmd.AssertOK() -} diff --git a/cmd/nerdctl/image_convert_test.go b/cmd/nerdctl/image_convert_test.go deleted file mode 100644 index 4853c1070ce..00000000000 --- a/cmd/nerdctl/image_convert_test.go +++ /dev/null @@ -1,53 +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 main - -import ( - "runtime" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestImageConvertEStargz(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no windows support yet") - } - testutil.DockerIncompatible(t) - t.Parallel() - base := testutil.NewBase(t) - convertedImage := testutil.Identifier(t) + ":esgz" - base.Cmd("rmi", convertedImage).Run() - defer base.Cmd("rmi", convertedImage).Run() - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("image", "convert", "--estargz", "--oci", - testutil.CommonImage, convertedImage).AssertOK() -} - -func TestImageConvertZstdChunked(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("no windows support yet") - } - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - convertedImage := testutil.Identifier(t) + ":zstdchunked" - base.Cmd("rmi", convertedImage).Run() - defer base.Cmd("rmi", convertedImage).Run() - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("image", "convert", "--zstdchunked", "--oci", "--zstdchunked-compression-level", "3", - testutil.CommonImage, convertedImage).AssertOK() -} diff --git a/cmd/nerdctl/image_encrypt_linux_test.go b/cmd/nerdctl/image_encrypt_linux_test.go deleted file mode 100644 index 9236a5ca33a..00000000000 --- a/cmd/nerdctl/image_encrypt_linux_test.go +++ /dev/null @@ -1,132 +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 main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "gotest.tools/v3/assert" -) - -type jweKeyPair struct { - prv string - pub string - cleanup func() -} - -func newJWEKeyPair(t testing.TB) *jweKeyPair { - testutil.RequireExecutable(t, "openssl") - td, err := os.MkdirTemp(t.TempDir(), "jwe-key-pair") - assert.NilError(t, err) - prv := filepath.Join(td, "mykey.pem") - pub := filepath.Join(td, "mypubkey.pem") - cmds := [][]string{ - // Exec openssl commands to ensure that nerdctl is compatible with the output of openssl commands. - // Do NOT refactor this function to use "crypto/rsa" stdlib. - {"openssl", "genrsa", "-out", prv}, - {"openssl", "rsa", "-in", prv, "-pubout", "-out", pub}, - } - for _, f := range cmds { - cmd := exec.Command(f[0], f[1:]...) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v (%q)", cmd.Args, err, string(out)) - } - } - return &jweKeyPair{ - prv: prv, - pub: pub, - cleanup: func() { - _ = os.RemoveAll(td) - }, - } -} - -func rmiAll(base *testutil.Base) { - base.T.Logf("Pruning images") - imageIDs := base.Cmd("images", "--no-trunc", "-a", "-q").OutLines() - // remove empty output line at the end - imageIDs = imageIDs[:len(imageIDs)-1] - // use `Run` on purpose (same below) because `rmi all` may fail on individual - // image id that has an expected running container (e.g. a registry) - base.Cmd(append([]string{"rmi", "-f"}, imageIDs...)...).Run() - - base.T.Logf("Pruning build caches") - if _, err := buildkitutil.GetBuildkitHost(testutil.Namespace); err == nil { - base.Cmd("builder", "prune").AssertOK() - } - - // For BuildKit >= 0.11, pruning cache isn't enough to remove manifest blobs that are referred by build history blobs - // https://github.com/containerd/nerdctl/pull/1833 - if base.Target == testutil.Nerdctl { - base.T.Logf("Pruning all content blobs") - addr := base.ContainerdAddress() - client, err := containerd.New(addr, containerd.WithDefaultNamespace(testutil.Namespace)) - assert.NilError(base.T, err) - cs := client.ContentStore() - ctx := context.TODO() - wf := func(info content.Info) error { - base.T.Logf("Pruning blob %+v", info) - if err := cs.Delete(ctx, info.Digest); err != nil { - base.T.Log(err) - } - return nil - } - if err := cs.Walk(ctx, wf); err != nil { - base.T.Log(err) - } - - base.T.Logf("Pruning all images (again?)") - imageIDs = base.Cmd("images", "--no-trunc", "-a", "-q").OutLines() - base.T.Logf("pruning following images: %+v", imageIDs) - base.Cmd(append([]string{"rmi", "-f"}, imageIDs...)...).Run() - } -} - -func TestImageEncryptJWE(t *testing.T) { - testutil.DockerIncompatible(t) - keyPair := newJWEKeyPair(t) - defer keyPair.cleanup() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - base.Cmd("pull", testutil.CommonImage).AssertOK() - encryptImageRef := fmt.Sprintf("127.0.0.1:%d/%s:encrypted", reg.ListenPort, tID) - defer base.Cmd("rmi", encryptImageRef).Run() - base.Cmd("image", "encrypt", "--recipient=jwe:"+keyPair.pub, testutil.CommonImage, encryptImageRef).AssertOK() - base.Cmd("image", "inspect", "--mode=native", "--format={{len .Index.Manifests}}", encryptImageRef).AssertOutExactly("1\n") - base.Cmd("image", "inspect", "--mode=native", "--format={{json .Manifest.Layers}}", encryptImageRef).AssertOutContains("org.opencontainers.image.enc.keys.jwe") - base.Cmd("push", encryptImageRef).AssertOK() - // remove all local images (in the nerdctl-test namespace), to ensure that we do not have blobs of the original image. - rmiAll(base) - base.Cmd("pull", encryptImageRef).AssertFail() // defaults to --unpack=true, and fails due to missing prv key - base.Cmd("pull", "--unpack=false", encryptImageRef).AssertOK() - decryptImageRef := tID + ":decrypted" - defer base.Cmd("rmi", decryptImageRef).Run() - base.Cmd("image", "decrypt", "--key="+keyPair.pub, encryptImageRef, decryptImageRef).AssertFail() // decryption needs prv key, not pub key - base.Cmd("image", "decrypt", "--key="+keyPair.prv, encryptImageRef, decryptImageRef).AssertOK() -} diff --git a/cmd/nerdctl/image_inspect_test.go b/cmd/nerdctl/image_inspect_test.go deleted file mode 100644 index ef35921ef03..00000000000 --- a/cmd/nerdctl/image_inspect_test.go +++ /dev/null @@ -1,47 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestImageInspectContainsSomeStuff(t *testing.T) { - base := testutil.NewBase(t) - - base.Cmd("pull", testutil.CommonImage).AssertOK() - inspect := base.InspectImage(testutil.CommonImage) - - assert.Assert(base.T, len(inspect.RootFS.Layers) > 0) - assert.Assert(base.T, inspect.RootFS.Type != "") - assert.Assert(base.T, inspect.Architecture != "") - assert.Assert(base.T, inspect.Size > 0) -} - -func TestImageInspectWithFormat(t *testing.T) { - base := testutil.NewBase(t) - - base.Cmd("pull", testutil.CommonImage).AssertOK() - // test RawFormat support - base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.Id}}").AssertOK() - - // test typedFormat support - base.Cmd("image", "inspect", testutil.CommonImage, "--format", "{{.ID}}").AssertOK() -} diff --git a/cmd/nerdctl/image_list_test.go b/cmd/nerdctl/image_list_test.go deleted file mode 100644 index 1b07841fcec..00000000000 --- a/cmd/nerdctl/image_list_test.go +++ /dev/null @@ -1,137 +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 main - -import ( - "fmt" - "os" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/tabutil" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestImagesWithNames(t *testing.T) { - t.Parallel() - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("images", "--names", testutil.CommonImage).AssertOutContains(testutil.CommonImage) - base.Cmd("images", "--names", testutil.CommonImage).AssertOutWithFunc(func(out string) error { - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) < 2 { - return fmt.Errorf("expected at least 2 lines, got %d", len(lines)) - } - tab := tabutil.NewReader("NAME\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE") - err := tab.ParseHeader(lines[0]) - if err != nil { - return fmt.Errorf("failed to parse header: %v", err) - } - name, _ := tab.ReadRow(lines[1], "NAME") - assert.Equal(t, name, testutil.CommonImage) - return nil - }) -} - -func TestImages(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - header := "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tPLATFORM\tSIZE\tBLOB SIZE" - if base.Target == testutil.Docker { - header = "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE" - } - - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("images", testutil.CommonImage).AssertOutWithFunc(func(out string) error { - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) < 2 { - return fmt.Errorf("expected at least 2 lines, got %d", len(lines)) - } - tab := tabutil.NewReader(header) - err := tab.ParseHeader(lines[0]) - if err != nil { - return fmt.Errorf("failed to parse header: %v", err) - } - repo, _ := tab.ReadRow(lines[1], "REPOSITORY") - tag, _ := tab.ReadRow(lines[1], "TAG") - assert.Equal(t, repo+":"+tag, testutil.CommonImage) - return nil - }) -} - -func TestImagesFilter(t *testing.T) { - testutil.RequiresBuild(t) - t.Parallel() - base := testutil.NewBase(t) - tempName := testutil.Identifier(base.T) - base.Cmd("pull", testutil.CommonImage).AssertOK() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] \n -LABEL foo=bar -LABEL version=0.1`, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - base.Cmd("build", "-t", tempName, "-f", buildCtx+"/Dockerfile", buildCtx).AssertOK() - defer base.Cmd("rmi", tempName).AssertOK() - - busyboxGlibc, busyboxUclibc := "busybox:glibc", "busybox:uclibc" - base.Cmd("pull", busyboxGlibc).AssertOK() - defer base.Cmd("rmi", busyboxGlibc).AssertOK() - - base.Cmd("pull", busyboxUclibc).AssertOK() - defer base.Cmd("rmi", busyboxUclibc).AssertOK() - - base.Cmd("images", "--filter", fmt.Sprintf("before=%s:%s", tempName, "latest")).AssertOutContains(testutil.ImageRepo(testutil.CommonImage)) - base.Cmd("images", "--filter", fmt.Sprintf("before=%s:%s", tempName, "latest")).AssertOutNotContains(tempName) - base.Cmd("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage)).AssertOutContains(tempName) - base.Cmd("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage)).AssertOutNotContains(testutil.ImageRepo(testutil.CommonImage)) - base.Cmd("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage), testutil.CommonImage).AssertOutNotContains(testutil.ImageRepo(testutil.CommonImage)) - base.Cmd("images", "--filter", fmt.Sprintf("since=%s", testutil.CommonImage), testutil.CommonImage).AssertOutNotContains(tempName) - base.Cmd("images", "--filter", "label=foo=bar").AssertOutContains(tempName) - base.Cmd("images", "--filter", "label=foo=bar1").AssertOutNotContains(tempName) - base.Cmd("images", "--filter", "label=foo=bar", "--filter", "label=version=0.1").AssertOutContains(tempName) - base.Cmd("images", "--filter", "label=foo=bar", "--filter", "label=version=0.2").AssertOutNotContains(tempName) - base.Cmd("images", "--filter", "label=version").AssertOutContains(tempName) - base.Cmd("images", "--filter", fmt.Sprintf("reference=%s*", tempName)).AssertOutContains(tempName) - base.Cmd("images", "--filter", "reference=busy*:*libc*").AssertOutContains("glibc") - base.Cmd("images", "--filter", "reference=busy*:*libc*").AssertOutContains("uclibc") -} - -func TestImagesFilterDangling(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - base.Cmd("images", "prune", "--all").AssertOK() - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-notag-string"] - `, testutil.CommonImage) - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - - defer os.RemoveAll(buildCtx) - base.Cmd("build", "-f", buildCtx+"/Dockerfile", buildCtx).AssertOK() - - // dangling image test - base.Cmd("images", "--filter", "dangling=true").AssertOutContains("") - base.Cmd("images", "--filter", "dangling=false").AssertOutNotContains("") -} diff --git a/cmd/nerdctl/image_load_linux_test.go b/cmd/nerdctl/image_load_linux_test.go deleted file mode 100644 index 0f620363a19..00000000000 --- a/cmd/nerdctl/image_load_linux_test.go +++ /dev/null @@ -1,57 +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 main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestLoadStdinFromPipe(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - - tmp := t.TempDir() - img := testutil.Identifier(t) + "image" - base.Cmd("pull", testutil.CommonImage).AssertOK() - base.Cmd("tag", testutil.CommonImage, img).AssertOK() - base.Cmd("save", img, "-o", filepath.Join(tmp, "common.tar")).AssertOK() - base.Cmd("rmi", "-f", img).AssertOK() - loadCmd := strings.Join(base.Cmd("load").Command, " ") - output := filepath.Join(tmp, "output") - - combined, err := exec.Command("sh", "-euxc", fmt.Sprintf("`cat %s/common.tar | %s > %s`", tmp, loadCmd, output)).CombinedOutput() - assert.NilError(t, err, "failed with error %s and combined output is %s", err, string(combined)) - fb, err := os.ReadFile(output) - assert.NilError(t, err) - - assert.Assert(t, strings.Contains(string(fb), fmt.Sprintf("Loaded image: %s:latest", img))) - base.Cmd("images").AssertOutContains(testutil.ImageRepo(testutil.CommonImage)) -} - -func TestLoadStdinEmpty(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - base.Cmd("load").AssertFail() -} diff --git a/cmd/nerdctl/image_prune_test.go b/cmd/nerdctl/image_prune_test.go deleted file mode 100644 index a185d6f33c2..00000000000 --- a/cmd/nerdctl/image_prune_test.go +++ /dev/null @@ -1,79 +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 main - -import ( - "fmt" - "os" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestImagePrune(t *testing.T) { - testutil.RequiresBuild(t) - - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").AssertOK() - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).AssertOK() - - dockerfile := fmt.Sprintf(`FROM %s - CMD ["echo", "nerdctl-test-image-prune"]`, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", buildCtx).AssertOK() - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("images").AssertOutContainsAll(imageName, "") - - base.Cmd("image", "prune", "--force").AssertNoOut(imageName) - base.Cmd("images").AssertNoOut("") - base.Cmd("images").AssertOutContains(imageName) -} - -func TestImagePruneAll(t *testing.T) { - testutil.RequiresBuild(t) - - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").AssertOK() - imageName := testutil.Identifier(t) - - dockerfile := fmt.Sprintf(`FROM %s - CMD ["echo", "nerdctl-test-image-prune"]`, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - // The following commands will clean up all images, so it should fail at this point. - defer base.Cmd("rmi", imageName).AssertFail() - base.Cmd("images").AssertOutContains(imageName) - - tID := testutil.Identifier(t) - base.Cmd("run", "--name", tID, imageName).AssertOK() - base.Cmd("image", "prune", "--force", "--all").AssertNoOut(imageName) - base.Cmd("images").AssertOutContains(imageName) - - base.Cmd("rm", "-f", tID).AssertOK() - base.Cmd("image", "prune", "--force", "--all").AssertOutContains(imageName) - base.Cmd("images").AssertNoOut(imageName) -} diff --git a/cmd/nerdctl/image_pull_linux_test.go b/cmd/nerdctl/image_pull_linux_test.go deleted file mode 100644 index 777e00120f8..00000000000 --- a/cmd/nerdctl/image_pull_linux_test.go +++ /dev/null @@ -1,147 +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 main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "gotest.tools/v3/assert" -) - -type cosignKeyPair struct { - publicKey string - privateKey string - cleanup func() -} - -func newCosignKeyPair(t testing.TB, path string) *cosignKeyPair { - td, err := os.MkdirTemp(t.TempDir(), path) - assert.NilError(t, err) - - cmd := exec.Command("cosign", "generate-key-pair") - cmd.Dir = td - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v (%q)", cmd.Args, err, string(out)) - } - - publicKey := filepath.Join(td, "cosign.pub") - privateKey := filepath.Join(td, "cosign.key") - - return &cosignKeyPair{ - publicKey: publicKey, - privateKey: privateKey, - cleanup: func() { - _ = os.RemoveAll(td) - }, - } -} - -func TestImageVerifyWithCosign(t *testing.T) { - testutil.RequireExecutable(t, "cosign") - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - t.Setenv("COSIGN_PASSWORD", "1") - keyPair := newCosignKeyPair(t, "cosign-key-pair") - defer keyPair.cleanup() - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - localhostIP := "127.0.0.1" - t.Logf("localhost IP=%q", localhostIP) - testImageRef := fmt.Sprintf("%s:%d/%s", - localhostIP, reg.ListenPort, tID) - t.Logf("testImageRef=%q", testImageRef) - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", testImageRef, buildCtx).AssertOK() - base.Cmd("push", testImageRef, "--sign=cosign", "--cosign-key="+keyPair.privateKey).AssertOK() - base.Cmd("pull", testImageRef, "--verify=cosign", "--cosign-key="+keyPair.publicKey).AssertOK() -} - -func TestImagePullPlainHttpWithDefaultPort(t *testing.T) { - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - reg := testregistry.NewPlainHTTP(base, 80) - defer reg.Cleanup() - testImageRef := fmt.Sprintf("%s/%s:%s", - reg.IP.String(), testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - t.Logf("testImageRef=%q", testImageRef) - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - base.Cmd("build", "-t", testImageRef, buildCtx).AssertOK() - base.Cmd("--insecure-registry", "push", testImageRef).AssertOK() - base.Cmd("--insecure-registry", "pull", testImageRef).AssertOK() -} - -func TestImageVerifyWithCosignShouldFailWhenKeyIsNotCorrect(t *testing.T) { - testutil.RequireExecutable(t, "cosign") - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - t.Setenv("COSIGN_PASSWORD", "1") - keyPair := newCosignKeyPair(t, "cosign-key-pair") - defer keyPair.cleanup() - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - tID := testutil.Identifier(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - localhostIP := "127.0.0.1" - t.Logf("localhost IP=%q", localhostIP) - testImageRef := fmt.Sprintf("%s:%d/%s", - localhostIP, reg.ListenPort, tID) - t.Logf("testImageRef=%q", testImageRef) - - dockerfile := fmt.Sprintf(`FROM %s -CMD ["echo", "nerdctl-build-test-string"] - `, testutil.CommonImage) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - base.Cmd("build", "-t", testImageRef, buildCtx).AssertOK() - base.Cmd("push", testImageRef, "--sign=cosign", "--cosign-key="+keyPair.privateKey).AssertOK() - base.Cmd("pull", testImageRef, "--verify=cosign", "--cosign-key="+keyPair.publicKey).AssertOK() - - t.Setenv("COSIGN_PASSWORD", "2") - newKeyPair := newCosignKeyPair(t, "cosign-key-pair-test") - base.Cmd("pull", testImageRef, "--verify=cosign", "--cosign-key="+newKeyPair.publicKey).AssertFail() -} diff --git a/cmd/nerdctl/image_push_linux_test.go b/cmd/nerdctl/image_push_linux_test.go deleted file mode 100644 index bfe8cb02bcf..00000000000 --- a/cmd/nerdctl/image_push_linux_test.go +++ /dev/null @@ -1,169 +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 main - -import ( - "fmt" - "net/http" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" - "gotest.tools/v3/assert" -) - -func TestPushPlainHTTPFails(t *testing.T) { - base := testutil.NewBase(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - reg.IP.String(), reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - res := base.Cmd("push", testImageRef).Run() - resCombined := res.Combined() - t.Logf("result: exitCode=%d, out=%q", res.ExitCode, res.Combined()) - assert.Assert(t, res.ExitCode != 0) - assert.Assert(t, strings.Contains(resCombined, "server gave HTTP response to HTTPS client")) -} - -func TestPushPlainHTTPLocalhost(t *testing.T) { - base := testutil.NewBase(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - localhostIP := "127.0.0.1" - t.Logf("localhost IP=%q", localhostIP) - - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - localhostIP, reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - base.Cmd("push", testImageRef).AssertOK() -} - -func TestPushPlainHTTPInsecure(t *testing.T) { - // Skip docker, because "dockerd --insecure-registries" requires restarting the daemon - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - reg.IP.String(), reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - base.Cmd("--insecure-registry", "push", testImageRef).AssertOK() -} - -func TestPushPlainHttpInsecureWithDefaultPort(t *testing.T) { - // Skip docker, because "dockerd --insecure-registries" requires restarting the daemon - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewPlainHTTP(base, 80) - defer reg.Cleanup() - - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s/%s:%s", - reg.IP.String(), testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - base.Cmd("--insecure-registry", "push", testImageRef).AssertOK() -} - -func TestPushInsecureWithLogin(t *testing.T) { - // Skip docker, because "dockerd --insecure-registries" requires restarting the daemon - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewHTTPS(base, "admin", "badmin") - defer reg.Cleanup() - - base.Cmd("--insecure-registry", "login", "-u", "admin", "-p", "badmin", - fmt.Sprintf("%s:%d", reg.IP.String(), reg.ListenPort)).AssertOK() - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - reg.IP.String(), reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - base.Cmd("push", testImageRef).AssertFail() - base.Cmd("--insecure-registry", "push", testImageRef).AssertOK() -} - -func TestPushWithHostsDir(t *testing.T) { - // Skip docker, because Docker doesn't have `--hosts-dir` option, and we don't want to contaminate the global /etc/docker/certs.d during this test - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewHTTPS(base, "admin", "badmin") - defer reg.Cleanup() - - base.Cmd("--hosts-dir", reg.HostsDir, "login", "-u", "admin", "-p", "badmin", fmt.Sprintf("%s:%d", reg.IP.String(), reg.ListenPort)).AssertOK() - - base.Cmd("pull", testutil.CommonImage).AssertOK() - testImageRef := fmt.Sprintf("%s:%d/%s:%s", - reg.IP.String(), reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.CommonImage, ":")[1]) - t.Logf("testImageRef=%q", testImageRef) - base.Cmd("tag", testutil.CommonImage, testImageRef).AssertOK() - - base.Cmd("--debug", "--hosts-dir", reg.HostsDir, "push", testImageRef).AssertOK() -} - -func TestPushNonDistributableArtifacts(t *testing.T) { - // Skip docker, because "dockerd --insecure-registries" requires restarting the daemon - // Skip docker, because "--allow-nondistributable-artifacts" is a daemon-only option and requires restarting the daemon - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewPlainHTTP(base, 5000) - defer reg.Cleanup() - - base.Cmd("pull", testutil.NonDistBlobImage).AssertOK() - - testImgRef := fmt.Sprintf("%s:%d/%s:%s", - reg.IP.String(), reg.ListenPort, testutil.Identifier(t), strings.Split(testutil.NonDistBlobImage, ":")[1]) - base.Cmd("tag", testutil.NonDistBlobImage, testImgRef).AssertOK() - - base.Cmd("--debug", "--insecure-registry", "push", testImgRef).AssertOK() - - blobURL := fmt.Sprintf("http://%s:%d/v2/%s/blobs/%s", reg.IP.String(), reg.ListenPort, testutil.Identifier(t), testutil.NonDistBlobDigest) - resp, err := http.Get(blobURL) - assert.Assert(t, err, "error making http request") - if resp.Body != nil { - resp.Body.Close() - } - assert.Equal(t, resp.StatusCode, http.StatusNotFound, "non-distributable blob should not be available") - - base.Cmd("--debug", "--insecure-registry", "push", "--allow-nondistributable-artifacts", testImgRef).AssertOK() - resp, err = http.Get(blobURL) - assert.Assert(t, err, "error making http request") - if resp.Body != nil { - resp.Body.Close() - } - assert.Equal(t, resp.StatusCode, http.StatusOK, "non-distributable blob should be available") -} diff --git a/cmd/nerdctl/image_remove_linux_test.go b/cmd/nerdctl/image_remove_linux_test.go deleted file mode 100644 index 7594798f62d..00000000000 --- a/cmd/nerdctl/image_remove_linux_test.go +++ /dev/null @@ -1,107 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestRemoveImage(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - base.Cmd("image", "prune", "--force", "--all").AssertOK() - - // ignore error - base.Cmd("rmi", "-f", tID).AssertOK() - - base.Cmd("run", "--name", tID, testutil.CommonImage).AssertOK() - defer base.Cmd("rm", "-f", tID).AssertOK() - - base.Cmd("rmi", testutil.CommonImage).AssertFail() - defer base.Cmd("rmi", "-f", testutil.CommonImage).Run() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertOK() - - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.CommonImage)) -} - -func TestRemoveRunningImage(t *testing.T) { - // If an image is associated with a running/paused containers, `docker rmi -f imageName` - // untags `imageName` (left a `` image) without deletion; `docker rmi -rf imageID` fails. - // In both cases, `nerdctl rmi -f` will fail. - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - base.Cmd("run", "--name", tID, "-d", testutil.CommonImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID).AssertOK() - - base.Cmd("rmi", testutil.CommonImage).AssertFail() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertFail() - base.Cmd("images").AssertOutContains(testutil.ImageRepo(testutil.CommonImage)) - - base.Cmd("kill", tID).AssertOK() - base.Cmd("rmi", testutil.CommonImage).AssertFail() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertOK() - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.CommonImage)) -} - -func TestRemovePausedImage(t *testing.T) { - // If an image is associated with a running/paused containers, `docker rmi -f imageName` - // untags `imageName` (left a `` image) without deletion; `docker rmi -rf imageID` fails. - // In both cases, `nerdctl rmi -f` will fail. - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - switch base.Info().CgroupDriver { - case "none", "": - t.Skip("requires cgroup (for pausing)") - } - tID := testutil.Identifier(t) - - base.Cmd("run", "--name", tID, "-d", testutil.CommonImage, "sleep", "infinity").AssertOK() - base.Cmd("pause", tID).AssertOK() - defer base.Cmd("rm", "-f", tID).AssertOK() - - base.Cmd("rmi", testutil.CommonImage).AssertFail() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertFail() - base.Cmd("images").AssertOutContains(testutil.ImageRepo(testutil.CommonImage)) - - base.Cmd("kill", tID).AssertOK() - base.Cmd("rmi", testutil.CommonImage).AssertFail() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertOK() - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.CommonImage)) -} - -func TestRemoveImageWithCreatedContainer(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - base.Cmd("pull", testutil.AlpineImage).AssertOK() - base.Cmd("pull", testutil.NginxAlpineImage).AssertOK() - - base.Cmd("create", "--name", tID, testutil.AlpineImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID).AssertOK() - - base.Cmd("rmi", testutil.AlpineImage).AssertFail() - base.Cmd("rmi", "-f", testutil.AlpineImage).AssertOK() - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.AlpineImage)) - - // a created container with removed image doesn't impact other `rmi` command - base.Cmd("rmi", "-f", testutil.NginxAlpineImage).AssertOK() - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.NginxAlpineImage)) -} diff --git a/cmd/nerdctl/image_save_linux_test.go b/cmd/nerdctl/image_save_linux_test.go deleted file mode 100644 index c2970a9f04c..00000000000 --- a/cmd/nerdctl/image_save_linux_test.go +++ /dev/null @@ -1,102 +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 main - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - - "gotest.tools/v3/assert" -) - -func TestSave(t *testing.T) { - base := testutil.NewBase(t) - base.Cmd("pull", testutil.AlpineImage).AssertOK() - archiveTarPath := filepath.Join(t.TempDir(), "a.tar") - base.Cmd("save", "-o", archiveTarPath, testutil.AlpineImage).AssertOK() - rootfsPath := filepath.Join(t.TempDir(), "rootfs") - err := extractDockerArchive(archiveTarPath, rootfsPath) - assert.NilError(t, err) - etcOSReleasePath := filepath.Join(rootfsPath, "/etc/os-release") - etcOSReleaseBytes, err := os.ReadFile(etcOSReleasePath) - assert.NilError(t, err) - etcOSRelease := string(etcOSReleaseBytes) - t.Logf("read %q, extracted from %q", etcOSReleasePath, testutil.AlpineImage) - t.Log(etcOSRelease) - assert.Assert(t, strings.Contains(etcOSRelease, "Alpine")) -} - -func extractDockerArchive(archiveTarPath, rootfsPath string) error { - if err := os.MkdirAll(rootfsPath, 0755); err != nil { - return err - } - workDir, err := os.MkdirTemp("", "extract-docker-archive") - if err != nil { - return err - } - defer os.RemoveAll(workDir) - if err := extractTarFile(workDir, archiveTarPath); err != nil { - return err - } - manifestJSONPath := filepath.Join(workDir, "manifest.json") - manifestJSONBytes, err := os.ReadFile(manifestJSONPath) - if err != nil { - return err - } - var mani DockerArchiveManifestJSON - if err := json.Unmarshal(manifestJSONBytes, &mani); err != nil { - return err - } - if len(mani) > 1 { - return fmt.Errorf("multi-image archive cannot be extracted: contains %d images", len(mani)) - } - if len(mani) < 1 { - return errors.New("invalid archive") - } - ent := mani[0] - for _, l := range ent.Layers { - layerTarPath := filepath.Join(workDir, l) - if err := extractTarFile(rootfsPath, layerTarPath); err != nil { - return err - } - } - return nil -} - -type DockerArchiveManifestJSON []DockerArchiveManifestJSONEntry - -type DockerArchiveManifestJSONEntry struct { - Config string - RepoTags []string - Layers []string -} - -func extractTarFile(dirPath, tarFilePath string) error { - cmd := exec.Command("tar", "Cxf", dirPath, tarFilePath) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err) - } - return nil -} diff --git a/cmd/nerdctl/image_save_test.go b/cmd/nerdctl/image_save_test.go deleted file mode 100644 index cd4106b8bf7..00000000000 --- a/cmd/nerdctl/image_save_test.go +++ /dev/null @@ -1,62 +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 main - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestSaveById(t *testing.T) { - base := testutil.NewBase(t) - base.Cmd("pull", testutil.CommonImage).AssertOK() - inspect := base.InspectImage(testutil.CommonImage) - var id string - if testutil.GetTarget() == testutil.Docker { - id = inspect.ID - } else { - id = strings.Split(inspect.RepoDigests[0], ":")[1] - } - archiveTarPath := filepath.Join(t.TempDir(), "id.tar") - base.Cmd("save", "-o", archiveTarPath, id).AssertOK() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertOK() - base.Cmd("load", "-i", archiveTarPath).AssertOK() - base.Cmd("run", "--rm", id, "sh", "-euxc", "echo foo").AssertOK() -} - -func TestSaveByIdWithDifferentNames(t *testing.T) { - base := testutil.NewBase(t) - base.Cmd("pull", testutil.CommonImage).AssertOK() - inspect := base.InspectImage(testutil.CommonImage) - var id string - if testutil.GetTarget() == testutil.Docker { - id = inspect.ID - } else { - id = strings.Split(inspect.RepoDigests[0], ":")[1] - } - - base.Cmd("tag", testutil.CommonImage, "foobar").AssertOK() - - archiveTarPath := filepath.Join(t.TempDir(), "id.tar") - base.Cmd("save", "-o", archiveTarPath, id).AssertOK() - base.Cmd("rmi", "-f", testutil.CommonImage).AssertOK() - base.Cmd("load", "-i", archiveTarPath).AssertOK() - base.Cmd("run", "--rm", id, "sh", "-euxc", "echo foo").AssertOK() -} diff --git a/cmd/nerdctl/inspect.go b/cmd/nerdctl/inspect/inspect.go similarity index 81% rename from cmd/nerdctl/inspect.go rename to cmd/nerdctl/inspect/inspect.go index 30204ca7c5c..a91624b27f1 100644 --- a/cmd/nerdctl/inspect.go +++ b/cmd/nerdctl/inspect/inspect.go @@ -14,23 +14,27 @@ limitations under the License. */ -package main +package inspect import ( "context" "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/cmd/image" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + containerCmd "github.com/containerd/nerdctl/v2/cmd/nerdctl/container" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + imageCmd "github.com/containerd/nerdctl/v2/cmd/nerdctl/image" + "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/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" ) -func newInspectCommand() *cobra.Command { +func NewInspectCommand() *cobra.Command { var inspectCommand = &cobra.Command{ Use: "inspect", Short: "Return low-level information on objects.", @@ -52,6 +56,8 @@ var validInspectType = map[string]bool{ } func addInspectFlags(cmd *cobra.Command) { + cmd.Flags().BoolP("size", "s", false, "Display total file sizes (for containers)") + 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) { return []string{"json"}, cobra.ShellCompDirectiveNoFileComp @@ -67,7 +73,7 @@ func addInspectFlags(cmd *cobra.Command) { } func inspectAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -111,13 +117,13 @@ func inspectAction(cmd *cobra.Command, args []string) error { var containerInspectOptions types.ContainerInspectOptions if inspectImage { platform := "" - imageInspectOptions, err = processImageInspectOptions(cmd, &platform) + imageInspectOptions, err = imageCmd.ProcessImageInspectOptions(cmd, &platform) if err != nil { return err } } if inspectContainer { - containerInspectOptions, err = processContainerInspectOptions(cmd) + containerInspectOptions, err = containerCmd.ProcessContainerInspectOptions(cmd) if err != nil { return err } @@ -163,8 +169,8 @@ func inspectAction(cmd *cobra.Command, args []string) error { func inspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show container names - containers, _ := shellCompleteContainerNames(cmd, nil) + containers, _ := completion.ContainerNames(cmd, nil) // show image names - images, _ := shellCompleteImageNames(cmd) + images, _ := completion.ImageNames(cmd) return append(containers, images...), cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/nerdctl/inspect/inspect_test.go b/cmd/nerdctl/inspect/inspect_test.go new file mode 100644 index 00000000000..18b6d6ffee5 --- /dev/null +++ b/cmd/nerdctl/inspect/inspect_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 inspect + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/internal.go b/cmd/nerdctl/internal/internal.go similarity index 93% rename from cmd/nerdctl/internal.go rename to cmd/nerdctl/internal/internal.go index 9f62d1ea2ae..c159452823a 100644 --- a/cmd/nerdctl/internal.go +++ b/cmd/nerdctl/internal/internal.go @@ -14,13 +14,13 @@ limitations under the License. */ -package main +package internal import ( "github.com/spf13/cobra" ) -func newInternalCommand() *cobra.Command { +func NewInternalCommand() *cobra.Command { var internalCommand = &cobra.Command{ Use: "internal", Short: "DO NOT EXECUTE MANUALLY", diff --git a/cmd/nerdctl/internal_oci_hook.go b/cmd/nerdctl/internal/internal_oci_hook.go similarity index 84% rename from cmd/nerdctl/internal_oci_hook.go rename to cmd/nerdctl/internal/internal_oci_hook.go index e75b32b66a7..31c8e5b910d 100644 --- a/cmd/nerdctl/internal_oci_hook.go +++ b/cmd/nerdctl/internal/internal_oci_hook.go @@ -14,16 +14,17 @@ limitations under the License. */ -package main +package internal import ( "errors" "os" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/ocihook" - "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/ocihook" ) func newInternalOCIHookCommandCommand() *cobra.Command { @@ -38,7 +39,7 @@ func newInternalOCIHookCommandCommand() *cobra.Command { } func internalOCIHookAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -55,9 +56,11 @@ func internalOCIHookAction(cmd *cobra.Command, args []string) error { } cniPath := globalOptions.CNIPath cniNetconfpath := globalOptions.CNINetConfPath + bridgeIP := globalOptions.BridgeIP return ocihook.Run(os.Stdin, os.Stderr, event, dataStore, cniPath, cniNetconfpath, + bridgeIP, ) } diff --git a/cmd/nerdctl/ipfs.go b/cmd/nerdctl/ipfs/ipfs.go similarity index 78% rename from cmd/nerdctl/ipfs.go rename to cmd/nerdctl/ipfs/ipfs.go index 871077e1e5f..e6e3a2194c8 100644 --- a/cmd/nerdctl/ipfs.go +++ b/cmd/nerdctl/ipfs/ipfs.go @@ -14,18 +14,20 @@ limitations under the License. */ -package main +package ipfs import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newIPFSCommand() *cobra.Command { +func NewIPFSCommand() *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "ipfs", Short: "Distributing images on IPFS", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go new file mode 100644 index 00000000000..e1b78cc4c1e --- /dev/null +++ b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go @@ -0,0 +1,326 @@ +/* + 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" + "io" + "strconv" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestIPFSCompNoBuild(t *testing.T) { + testCase := nerdtest.Setup() + + const ipfsAddrKey = "ipfsAddrKey" + + var ipfsRegistry *registry.Server + + testCase.Require = test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.Registry, + nerdtest.IPFS, + nerdtest.IsFlaky("https://github.com/containerd/nerdctl/issues/3510"), + // See note below + // nerdtest.Private, + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // Start Kubo + ipfsRegistry = registry.NewKuboRegistry(data, helpers, t, nil, 0, nil) + ipfsRegistry.Setup(data, helpers) + data.Set(ipfsAddrKey, fmt.Sprintf("/ip4/%s/tcp/%d", ipfsRegistry.IP, ipfsRegistry.Port)) + + // Ensure we have the images + helpers.Ensure("pull", "--quiet", testutil.WordpressImage) + helpers.Ensure("pull", "--quiet", testutil.MariaDBImage) + } + + testCase.SubTests = []*test.Case{ + subtestTestIPFSCompNoB(t, false, false), + subtestTestIPFSCompNoB(t, true, false), + subtestTestIPFSCompNoB(t, false, true), + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if ipfsRegistry != nil { + ipfsRegistry.Cleanup(data, helpers) + } + helpers.Anyhow("rmi", "-f", testutil.WordpressImage) + helpers.Anyhow("rmi", "-f", testutil.MariaDBImage) + } + + testCase.Run(t) +} + +func subtestTestIPFSCompNoB(t *testing.T, stargz bool, byAddr bool) *test.Case { + t.Helper() + + const ipfsAddrKey = "ipfsAddrKey" + const mariaImageCIDKey = "mariaImageCIDKey" + const wordpressImageCIDKey = "wordpressImageCIDKey" + const composeExtraKey = "composeExtraKey" + + testCase := &test.Case{} + + testCase.Description += "with" + + if !stargz { + testCase.Description += "-no" + } + testCase.Description += "-stargz" + + if !byAddr { + testCase.Description += "-no" + } + testCase.Description += "-byAddr" + + if stargz { + testCase.Require = nerdtest.Stargz + } + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var ipfsCIDWP, ipfsCIDMD string + if stargz { + ipfsCIDWP = pushToIPFS(helpers, testutil.WordpressImage, "--estargz") + ipfsCIDMD = pushToIPFS(helpers, testutil.MariaDBImage, "--estargz") + } else if byAddr { + ipfsCIDWP = pushToIPFS(helpers, testutil.WordpressImage, "--ipfs-address="+data.Get(ipfsAddrKey)) + ipfsCIDMD = pushToIPFS(helpers, testutil.MariaDBImage, "--ipfs-address="+data.Get(ipfsAddrKey)) + data.Set(composeExtraKey, "--ipfs-address="+data.Get(ipfsAddrKey)) + } else { + ipfsCIDWP = pushToIPFS(helpers, testutil.WordpressImage) + ipfsCIDMD = pushToIPFS(helpers, testutil.MariaDBImage) + } + data.Set(wordpressImageCIDKey, ipfsCIDWP) + data.Set(mariaImageCIDKey, ipfsCIDMD) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + // NOTE: + // Removing these images locally forces tests to be sequentials (as IPFS being content addressable, + // they have the same cid - except for the estargz version obviously) + // Deliberately electing to not remove them here so that we can parallelize and cut down the running time + /* + if data.Get(mariaImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mariaImageCIDKey)) + helpers.Anyhow("rmi", "-f", data.Get(wordpressImageCIDKey)) + } + */ + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + safePort, err := portlock.Acquire(0) + assert.NilError(helpers.T(), err) + data.Set("wordpressPort", strconv.Itoa(safePort)) + composeUP(data, helpers, fmt.Sprintf(` +version: '3.1' + +services: + + wordpress: + image: ipfs://%s + restart: always + ports: + - %d:80 + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: exampleuser + WORDPRESS_DB_PASSWORD: examplepass + WORDPRESS_DB_NAME: exampledb + # FIXME: this is flaky and will make the container fail on occasions + volumes: + - wordpress:/var/www/html + + db: + image: ipfs://%s + restart: always + environment: + MYSQL_DATABASE: exampledb + MYSQL_USER: exampleuser + MYSQL_PASSWORD: examplepass + MYSQL_RANDOM_ROOT_PASSWORD: '1' + volumes: + - db:/var/lib/mysql + +volumes: + wordpress: + db: +`, data.Get(wordpressImageCIDKey), safePort, data.Get(mariaImageCIDKey)), data.Get(composeExtraKey)) + // FIXME: need to break down composeUP into testable commands instead + // Right now, this is just a dummy placeholder + return helpers.Command("info") + } + + testCase.Expected = test.Expects(0, nil, nil) + + return testCase +} + +func TestIPFSCompBuild(t *testing.T) { + testCase := nerdtest.Setup() + + var ipfsServer test.TestableCommand + var comp *testutil.ComposeDir + + const mainImageCIDKey = "mainImageCIDKey" + safePort, err := portlock.Acquire(0) + assert.NilError(t, err) + var listenAddr = "localhost:" + strconv.Itoa(safePort) + + testCase.Require = test.Require( + // Linux only + test.Linux, + // Obviously not docker supported + test.Not(nerdtest.Docker), + nerdtest.Build, + nerdtest.IPFS, + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + // Get alpine + helpers.Ensure("pull", "--quiet", testutil.NginxAlpineImage) + // Start a local ipfs backed registry + // FIXME: this is bad and likely to collide with other tests + ipfsServer = helpers.Command("ipfs", "registry", "serve", "--listen-registry", listenAddr) + // Once foregrounded, do not wait for it more than a second + ipfsServer.Background(1 * time.Second) + // Apparently necessary to let it start... + time.Sleep(time.Second) + + // Save nginx to ipfs + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.NginxAlpineImage)) + + const dockerComposeYAML = ` +services: + web: + build: . + ports: + - 8081:80 +` + dockerfile := fmt.Sprintf(`FROM %s/ipfs/%s +COPY index.html /usr/share/nginx/html/index.html +`, listenAddr, data.Get(mainImageCIDKey)) + + comp = testutil.NewComposeDir(t, dockerComposeYAML) + comp.WriteFile("Dockerfile", dockerfile) + comp.WriteFile("index.html", data.Identifier("indexhtml")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if ipfsServer != nil { + // Close the server once done + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + ipfsServer.Run(nil) + } + if comp != nil { + helpers.Anyhow("compose", "-f", comp.YAMLFullPath(), "down", "-v") + comp.CleanUp() + } + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("compose", "-f", comp.YAMLFullPath(), "up", "-d", "--build") + } + + 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) + 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), data.Identifier("indexhtml"))) + }, + } + } + + testCase.Run(t) +} + +func composeUP(data test.Data, helpers test.Helpers, dockerComposeYAML string, opts string) { + comp := testutil.NewComposeDir(helpers.T(), dockerComposeYAML) + // defer comp.CleanUp() + + // Because it might or might not happen, and + helpers.Anyhow("compose", "-f", comp.YAMLFullPath(), "down", "-v") + defer helpers.Anyhow("compose", "-f", comp.YAMLFullPath(), "down", "-v") + + projectName := comp.ProjectName() + + args := []string{"compose", "-f", comp.YAMLFullPath()} + if opts != "" { + args = append(args, opts) + } + + helpers.Ensure(append(args, "up", "--quiet-pull", "-d")...) + + helpers.Ensure("volume", "inspect", fmt.Sprintf("%s_db", projectName)) + helpers.Ensure("network", "inspect", fmt.Sprintf("%s_default", projectName)) + + checkWordpress := func() error { + // FIXME: see other notes on using the same port repeatedly + resp, err := nettestutil.HTTPGet("http://127.0.0.1:"+data.Get("wordpressPort"), 5, false) + if err != nil { + return err + } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if !strings.Contains(string(respBody), testutil.WordpressIndexHTMLSnippet) { + return fmt.Errorf("respBody does not contain %q (%s)", testutil.WordpressIndexHTMLSnippet, string(respBody)) + } + return nil + } + + var wordpressWorking bool + var err error + // 15 seconds is long enough + for i := 0; i < 5; i++ { + err = checkWordpress() + if err == nil { + wordpressWorking = true + break + } + time.Sleep(3 * time.Second) + } + + 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.Ensure("compose", "-f", comp.YAMLFullPath(), "down", "-v") + helpers.Fail("volume", "inspect", fmt.Sprintf("%s_db", projectName)) + helpers.Fail("network", "inspect", fmt.Sprintf("%s_default", projectName)) +} diff --git a/cmd/nerdctl/ipfs/ipfs_kubo_linux_test.go b/cmd/nerdctl/ipfs/ipfs_kubo_linux_test.go new file mode 100644 index 00000000000..cb21a839779 --- /dev/null +++ b/cmd/nerdctl/ipfs/ipfs_kubo_linux_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 ipfs + +import ( + "fmt" + "regexp" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestIPFSAddrWithKubo(t *testing.T) { + testCase := nerdtest.Setup() + + const mainImageCIDKey = "mainImagemainImageCIDKey" + const ipfsAddrKey = "ipfsAddrKey" + + var ipfsRegistry *registry.Server + + testCase.Require = test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.Registry, + nerdtest.Private, + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + + ipfsRegistry = registry.NewKuboRegistry(data, helpers, t, nil, 0, nil) + ipfsRegistry.Setup(data, helpers) + ipfsAddr := fmt.Sprintf("/ip4/%s/tcp/%d", ipfsRegistry.IP, ipfsRegistry.Port) + data.Set(ipfsAddrKey, ipfsAddr) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if ipfsRegistry != nil { + ipfsRegistry.Cleanup(data, helpers) + } + } + + testCase.SubTests = []*test.Case{ + { + Description: "with default snapshotter", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + ipfsCID := pushToIPFS(helpers, testutil.CommonImage, fmt.Sprintf("--ipfs-address=%s", data.Get(ipfsAddrKey))) + helpers.Ensure("pull", "--ipfs-address", data.Get(ipfsAddrKey), "ipfs://"+ipfsCID) + data.Set(mainImageCIDKey, ipfsCID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(mainImageCIDKey), "echo", "hello") + }, + Expected: test.Expects(0, nil, test.Equals("hello\n")), + }, + { + Description: "with stargz snapshotter", + NoParallel: true, + Require: test.Require( + nerdtest.Stargz, + nerdtest.Private, + nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3475"), + ), + Setup: func(data test.Data, helpers test.Helpers) { + ipfsCID := pushToIPFS(helpers, testutil.CommonImage, fmt.Sprintf("--ipfs-address=%s", data.Get(ipfsAddrKey)), "--estargz") + helpers.Ensure("pull", "--ipfs-address", data.Get(ipfsAddrKey), "ipfs://"+ipfsCID) + data.Set(mainImageCIDKey, ipfsCID) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(mainImageCIDKey), "ls", "/.stargz-snapshotter") + }, + Expected: test.Expects(0, nil, test.Match(regexp.MustCompile("sha256:.*[.]json[\n]"))), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/ipfs_registry.go b/cmd/nerdctl/ipfs/ipfs_registry.go similarity index 78% rename from cmd/nerdctl/ipfs_registry.go rename to cmd/nerdctl/ipfs/ipfs_registry.go index 3211ad6ed8c..9beb019f8f3 100644 --- a/cmd/nerdctl/ipfs_registry.go +++ b/cmd/nerdctl/ipfs/ipfs_registry.go @@ -14,19 +14,21 @@ limitations under the License. */ -package main +package ipfs import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) func newIPFSRegistryCommand() *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "registry", Short: "Manage read-only registry backed by IPFS", - PreRunE: checkExperimental("ipfs"), - RunE: unknownSubcommandAction, + PreRunE: helpers.CheckExperimental("ipfs"), + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go new file mode 100644 index 00000000000..3b4f61183e8 --- /dev/null +++ b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go @@ -0,0 +1,150 @@ +/* + 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" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func pushToIPFS(helpers test.Helpers, name string, opts ...string) string { + var ipfsCID string + cmd := helpers.Command("push", "ipfs://"+name) + cmd.WithArgs(opts...) + cmd.Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + lines := strings.Split(stdout, "\n") + assert.Equal(t, len(lines) >= 2, true) + ipfsCID = lines[len(lines)-2] + }, + }) + return ipfsCID +} + +func TestIPFSNerdctlRegistry(t *testing.T) { + testCase := nerdtest.Setup() + + // FIXME: this is bad and likely to collide with other tests + const listenAddr = "localhost:5555" + + const ipfsImageURLKey = "ipfsImageURLKey" + + var ipfsServer test.TestableCommand + + testCase.Require = test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.IPFS, + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + + // Start a local ipfs backed registry + ipfsServer = helpers.Command("ipfs", "registry", "serve", "--listen-registry", listenAddr) + // Once foregrounded, do not wait for it more than a second + ipfsServer.Background(1 * time.Second) + // Apparently necessary to let it start... + time.Sleep(time.Second) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if ipfsServer != nil { + // Close the server once done + ipfsServer.Run(nil) + } + } + + testCase.SubTests = []*test.Case{ + { + Description: "with default snapshotter", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(ipfsImageURLKey, listenAddr+"/ipfs/"+pushToIPFS(helpers, testutil.CommonImage)) + helpers.Ensure("pull", "--quiet", data.Get(ipfsImageURLKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(ipfsImageURLKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(ipfsImageURLKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(ipfsImageURLKey), "echo", "hello") + }, + Expected: test.Expects(0, nil, test.Equals("hello\n")), + }, + { + Description: "with stargz snapshotterr", + NoParallel: true, + Require: nerdtest.Stargz, + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(ipfsImageURLKey, listenAddr+"/ipfs/"+pushToIPFS(helpers, testutil.CommonImage, "--estargz")) + helpers.Ensure("pull", "--quiet", data.Get(ipfsImageURLKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(ipfsImageURLKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(ipfsImageURLKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(ipfsImageURLKey), "ls", "/.stargz-snapshotter") + }, + Expected: test.Expects(0, nil, test.Match(regexp.MustCompile("sha256:.*[.]json[\n]"))), + }, + { + Description: "with build", + NoParallel: true, + Require: nerdtest.Build, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("built-image")) + if data.Get(ipfsImageURLKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(ipfsImageURLKey)) + } + }, + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(ipfsImageURLKey, listenAddr+"/ipfs/"+pushToIPFS(helpers, testutil.CommonImage)) + + dockerfile := fmt.Sprintf(`FROM %s +CMD ["echo", "nerdctl-build-test-string"] + `, data.Get(ipfsImageURLKey)) + + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + + helpers.Ensure("build", "-t", data.Identifier("built-image"), buildCtx) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Identifier("built-image")) + }, + Expected: test.Expects(0, nil, test.Equals("nerdctl-build-test-string\n")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/ipfs_registry_serve.go b/cmd/nerdctl/ipfs/ipfs_registry_serve.go similarity index 68% rename from cmd/nerdctl/ipfs_registry_serve.go rename to cmd/nerdctl/ipfs/ipfs_registry_serve.go index 81708d2c56e..739d3d3ece6 100644 --- a/cmd/nerdctl/ipfs_registry_serve.go +++ b/cmd/nerdctl/ipfs/ipfs_registry_serve.go @@ -14,12 +14,14 @@ limitations under the License. */ -package main +package ipfs import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/ipfs" "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/ipfs" ) const ( @@ -37,10 +39,10 @@ func newIPFSRegistryServeCommand() *cobra.Command { SilenceErrors: true, } - AddStringFlag(ipfsRegistryServeCommand, "listen-registry", nil, defaultIPFSRegistry, "IPFS_REGISTRY_SERVE_LISTEN_REGISTRY", "address to listen") - AddStringFlag(ipfsRegistryServeCommand, "ipfs-address", nil, "", "IPFS_REGISTRY_SERVE_IPFS_ADDRESS", "multiaddr of IPFS API (default is pulled from $IPFS_PATH/api file. If $IPFS_PATH env var is not present, it defaults to ~/.ipfs)") - AddIntFlag(ipfsRegistryServeCommand, "read-retry-num", nil, defaultIPFSReadRetryNum, "IPFS_REGISTRY_SERVE_READ_RETRY_NUM", "times to retry query on IPFS. Zero or lower means no retry.") - AddDurationFlag(ipfsRegistryServeCommand, "read-timeout", nil, defaultIPFSReadTimeoutDuration, "IPFS_REGISTRY_SERVE_READ_TIMEOUT", "timeout duration of a read request to IPFS. Zero means no timeout.") + helpers.AddStringFlag(ipfsRegistryServeCommand, "listen-registry", nil, defaultIPFSRegistry, "IPFS_REGISTRY_SERVE_LISTEN_REGISTRY", "address to listen") + helpers.AddStringFlag(ipfsRegistryServeCommand, "ipfs-address", nil, "", "IPFS_REGISTRY_SERVE_IPFS_ADDRESS", "multiaddr of IPFS API (default is pulled from $IPFS_PATH/api file. If $IPFS_PATH env var is not present, it defaults to ~/.ipfs)") + helpers.AddIntFlag(ipfsRegistryServeCommand, "read-retry-num", nil, defaultIPFSReadRetryNum, "IPFS_REGISTRY_SERVE_READ_RETRY_NUM", "times to retry query on IPFS. Zero or lower means no retry.") + helpers.AddDurationFlag(ipfsRegistryServeCommand, "read-timeout", nil, defaultIPFSReadTimeoutDuration, "IPFS_REGISTRY_SERVE_READ_TIMEOUT", "timeout duration of a read request to IPFS. Zero means no timeout.") return ipfsRegistryServeCommand } diff --git a/cmd/nerdctl/ipfs/ipfs_simple_linux_test.go b/cmd/nerdctl/ipfs/ipfs_simple_linux_test.go new file mode 100644 index 00000000000..48bc874049b --- /dev/null +++ b/cmd/nerdctl/ipfs/ipfs_simple_linux_test.go @@ -0,0 +1,229 @@ +/* + 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 ( + "regexp" + "testing" + + testhelpers "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestIPFSSimple(t *testing.T) { + testCase := nerdtest.Setup() + + const mainImageCIDKey = "mainImageCIDKey" + const transformedImageCIDKey = "transformedImageCIDKey" + + testCase.Require = test.Require( + test.Linux, + test.Not(nerdtest.Docker), + nerdtest.IPFS, + // We constantly rmi the image by its CID which is shared across tests, so, we make this group private + // and every subtest NoParallel + nerdtest.Private, + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + } + + testCase.SubTests = []*test.Case{ + { + Description: "with default snapshotter", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.CommonImage)) + helpers.Ensure("pull", "ipfs://"+data.Get(mainImageCIDKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(mainImageCIDKey), "echo", "hello") + }, + Expected: test.Expects(0, nil, test.Equals("hello\n")), + }, + { + Description: "with stargz snapshotter", + NoParallel: true, + Require: test.Require( + nerdtest.Stargz, + nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3475"), + ), + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.CommonImage, "--estargz")) + helpers.Ensure("pull", "ipfs://"+data.Get(mainImageCIDKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(mainImageCIDKey), "ls", "/.stargz-snapshotter") + }, + Expected: test.Expects(0, nil, test.Match(regexp.MustCompile("sha256:.*[.]json[\n]"))), + }, + { + Description: "with commit and push", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.CommonImage)) + helpers.Ensure("pull", "ipfs://"+data.Get(mainImageCIDKey)) + + // Run a container that does modify something, then commit and push it + helpers.Ensure("run", "--name", data.Identifier("commit-container"), data.Get(mainImageCIDKey), "sh", "-c", "--", "echo hello > /hello") + helpers.Ensure("commit", data.Identifier("commit-container"), data.Identifier("commit-image")) + data.Set(transformedImageCIDKey, pushToIPFS(helpers, data.Identifier("commit-image"))) + + // Clean-up + helpers.Ensure("rm", data.Identifier("commit-container")) + helpers.Ensure("rmi", data.Identifier("commit-image")) + + // Pull back the committed image + helpers.Ensure("pull", "ipfs://"+data.Get(transformedImageCIDKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("commit-container")) + helpers.Anyhow("rmi", "-f", data.Identifier("commit-image")) + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + helpers.Anyhow("rmi", "-f", data.Get(transformedImageCIDKey)) + } + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(transformedImageCIDKey), "cat", "/hello") + }, + + Expected: test.Expects(0, nil, test.Equals("hello\n")), + }, + { + Description: "with commit and push, stargz lazy pulling", + NoParallel: true, + Require: test.Require( + nerdtest.Stargz, + nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3475"), + ), + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.CommonImage, "--estargz")) + helpers.Ensure("pull", "ipfs://"+data.Get(mainImageCIDKey)) + + // Run a container that does modify something, then commit and push it + helpers.Ensure("run", "--name", data.Identifier("commit-container"), data.Get(mainImageCIDKey), "sh", "-c", "--", "echo hello > /hello") + helpers.Ensure("commit", data.Identifier("commit-container"), data.Identifier("commit-image")) + data.Set(transformedImageCIDKey, pushToIPFS(helpers, data.Identifier("commit-image"))) + + // Clean-up + helpers.Ensure("rm", data.Identifier("commit-container")) + helpers.Ensure("rmi", data.Identifier("commit-image")) + + // Pull back the image + helpers.Ensure("pull", "ipfs://"+data.Get(transformedImageCIDKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("commit-container")) + helpers.Anyhow("rmi", "-f", data.Identifier("commit-image")) + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + helpers.Anyhow("rmi", "-f", data.Get(transformedImageCIDKey)) + } + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", data.Get(transformedImageCIDKey), "sh", "-c", "--", "cat /hello && ls /.stargz-snapshotter") + }, + + Expected: test.Expects(0, nil, test.Match(regexp.MustCompile("hello[\n]sha256:.*[.]json[\n]"))), + }, + { + Description: "with encryption", + NoParallel: true, + Require: test.Binary("openssl"), + Setup: func(data test.Data, helpers test.Helpers) { + data.Set(mainImageCIDKey, pushToIPFS(helpers, testutil.CommonImage)) + helpers.Ensure("pull", "ipfs://"+data.Get(mainImageCIDKey)) + + // Prep a key pair + keyPair := testhelpers.NewJWEKeyPair(t) + // FIXME: this will only cleanup when the group is done, not right, but it works + t.Cleanup(keyPair.Cleanup) + data.Set("pub", keyPair.Pub) + data.Set("prv", keyPair.Prv) + + // Encrypt the image, and verify it is encrypted + helpers.Ensure("image", "encrypt", "--recipient=jwe:"+keyPair.Pub, data.Get(mainImageCIDKey), data.Identifier("encrypted")) + cmd := helpers.Command("image", "inspect", "--mode=native", "--format={{len .Index.Manifests}}", data.Identifier("encrypted")) + cmd.Run(&test.Expected{ + ExitCode: 1, + Output: test.Equals("1\n"), + }) + cmd = helpers.Command("image", "inspect", "--mode=native", "--format={{json (index .Manifest.Layers 0) }}", data.Identifier("encrypted")) + cmd.Run(&test.Expected{ + ExitCode: 1, + Output: test.Contains("org.opencontainers.image.enc.keys.jwe"), + }) + + // Push the encrypted image and save the CID + data.Set(transformedImageCIDKey, pushToIPFS(helpers, data.Identifier("encrypted"))) + + // Remove both images locally + helpers.Ensure("rmi", "-f", data.Get(mainImageCIDKey)) + helpers.Ensure("rmi", "-f", data.Get(transformedImageCIDKey)) + + // Pull back without unpacking + helpers.Ensure("pull", "--unpack=false", "ipfs://"+data.Get(transformedImageCIDKey)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get(mainImageCIDKey) != "" { + helpers.Anyhow("rmi", "-f", data.Get(mainImageCIDKey)) + helpers.Anyhow("rmi", "-f", data.Get(transformedImageCIDKey)) + } + }, + SubTests: []*test.Case{ + { + Description: "decrypt with pub key does not work", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("decrypted")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "decrypt", "--key="+data.Get("pub"), data.Get(transformedImageCIDKey), data.Identifier("decrypted")) + }, + Expected: test.Expects(1, nil, nil), + }, + { + Description: "decrypt with priv key does work", + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier("decrypted")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "decrypt", "--key="+data.Get("prv"), data.Get(transformedImageCIDKey), data.Identifier("decrypted")) + }, + Expected: test.Expects(0, nil, nil), + }, + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/ipfs/ipfs_test.go b/cmd/nerdctl/ipfs/ipfs_test.go new file mode 100644 index 00000000000..1b6749bee5f --- /dev/null +++ b/cmd/nerdctl/ipfs/ipfs_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 ipfs + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/ipfs_build_linux_test.go b/cmd/nerdctl/ipfs_build_linux_test.go deleted file mode 100644 index e7af2400786..00000000000 --- a/cmd/nerdctl/ipfs_build_linux_test.go +++ /dev/null @@ -1,69 +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 main - -import ( - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/containerd/nerdctl/pkg/testutil" - - "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" -) - -func TestIPFSBuild(t *testing.T) { - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage) - ipfsCIDBase := strings.TrimPrefix(ipfsCID, "ipfs://") - - imageName := testutil.Identifier(t) - defer base.Cmd("rmi", imageName).Run() - - dockerfile := fmt.Sprintf(`FROM localhost:5050/ipfs/%s -CMD ["echo", "nerdctl-build-test-string"] - `, ipfsCIDBase) - - buildCtx, err := createBuildContext(dockerfile) - assert.NilError(t, err) - defer os.RemoveAll(buildCtx) - - done := ipfsRegistryUp(t, base) - defer done() - base.Cmd("build", "-t", imageName, buildCtx).AssertOK() - base.Cmd("build", buildCtx, "-t", imageName).AssertOK() - - base.Cmd("run", "--rm", imageName).AssertOutContains("nerdctl-build-test-string") -} - -func ipfsRegistryUp(t *testing.T, base *testutil.Base, args ...string) (done func() error) { - res := icmd.StartCmd(base.Cmd(append([]string{"ipfs", "registry", "serve"}, args...)...).Cmd) - time.Sleep(time.Second) - assert.Assert(t, res.Cmd.Process != nil) - assert.NilError(t, res.Error) - return func() error { - res.Cmd.Process.Kill() - icmd.WaitOnCmd(3*time.Second, res) - return nil - } -} diff --git a/cmd/nerdctl/ipfs_compose_linux_test.go b/cmd/nerdctl/ipfs_compose_linux_test.go deleted file mode 100644 index 087fbed2e8a..00000000000 --- a/cmd/nerdctl/ipfs_compose_linux_test.go +++ /dev/null @@ -1,149 +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 main - -import ( - "fmt" - "io" - "os" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "gotest.tools/v3/assert" -) - -func TestIPFSComposeUp(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - ipfsaddr, done := runIPFSDaemonContainer(t, base) - defer done() - tests := []struct { - name string - snapshotter string - pushOptions []string - composeOptions []string - requiresStargz bool - }{ - { - name: "overlayfs", - snapshotter: "overlayfs", - }, - { - name: "stargz", - snapshotter: "stargz", - pushOptions: []string{"--estargz"}, - requiresStargz: true, - }, - { - name: "ipfs-address", - snapshotter: "overlayfs", - pushOptions: []string{fmt.Sprintf("--ipfs-address=%s", ipfsaddr)}, - composeOptions: []string{fmt.Sprintf("--ipfs-address=%s", ipfsaddr)}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - base := testutil.NewBase(t) - if tt.requiresStargz { - requiresStargz(base) - } - ipfsImgs := make([]string, 2) - for i, img := range []string{testutil.WordpressImage, testutil.MariaDBImage} { - ipfsImgs[i] = pushImageToIPFS(t, base, img, tt.pushOptions...) - } - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER="+tt.snapshotter) - testComposeUp(t, base, fmt.Sprintf(` -version: '3.1' - -services: - - wordpress: - image: %s - restart: always - ports: - - 8080:80 - environment: - WORDPRESS_DB_HOST: db - WORDPRESS_DB_USER: exampleuser - WORDPRESS_DB_PASSWORD: examplepass - WORDPRESS_DB_NAME: exampledb - volumes: - # workaround for https://github.com/containerd/stargz-snapshotter/issues/444 - - "/run" - - wordpress:/var/www/html - - db: - image: %s - restart: always - environment: - MYSQL_DATABASE: exampledb - MYSQL_USER: exampleuser - MYSQL_PASSWORD: examplepass - MYSQL_RANDOM_ROOT_PASSWORD: '1' - volumes: - # workaround for https://github.com/containerd/stargz-snapshotter/issues/444 - - "/run" - - db:/var/lib/mysql - -volumes: - wordpress: - db: -`, ipfsImgs[0], ipfsImgs[1]), tt.composeOptions...) - }) - } -} - -func TestIPFSComposeUpBuild(t *testing.T) { - testutil.DockerIncompatible(t) - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - defer base.Cmd("builder", "prune").Run() - ipfsCID := pushImageToIPFS(t, base, testutil.NginxAlpineImage) - ipfsCIDBase := strings.TrimPrefix(ipfsCID, "ipfs://") - - const dockerComposeYAML = ` -services: - web: - build: . - ports: - - 8080:80 -` - dockerfile := fmt.Sprintf(`FROM localhost:5050/ipfs/%s -COPY index.html /usr/share/nginx/html/index.html -`, ipfsCIDBase) - indexHTML := t.Name() - - comp := testutil.NewComposeDir(t, dockerComposeYAML) - defer comp.CleanUp() - - comp.WriteFile("Dockerfile", dockerfile) - comp.WriteFile("index.html", indexHTML) - - done := ipfsRegistryUp(t, base) - defer done() - base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d", "--build").AssertOK() - defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() - - 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)) -} diff --git a/cmd/nerdctl/ipfs_linux_test.go b/cmd/nerdctl/ipfs_linux_test.go deleted file mode 100644 index 523d5eb981f..00000000000 --- a/cmd/nerdctl/ipfs_linux_test.go +++ /dev/null @@ -1,162 +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 main - -import ( - "fmt" - "os" - "regexp" - "testing" - - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - - "gotest.tools/v3/assert" -) - -func TestIPFS(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage) - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=overlayfs") - base.Cmd("pull", ipfsCID).AssertOK() - base.Cmd("run", "--rm", ipfsCID, "echo", "hello").AssertOK() - - // encryption - keyPair := newJWEKeyPair(t) - defer keyPair.cleanup() - tID := testutil.Identifier(t) - encryptImageRef := tID + ":enc" - layersNum := 1 - base.Cmd("image", "encrypt", "--recipient=jwe:"+keyPair.pub, ipfsCID, encryptImageRef).AssertOK() - base.Cmd("image", "inspect", "--mode=native", "--format={{len .Manifest.Layers}}", encryptImageRef).AssertOutExactly(fmt.Sprintf("%d\n", layersNum)) - for i := 0; i < layersNum; i++ { - base.Cmd("image", "inspect", "--mode=native", fmt.Sprintf("--format={{json (index .Manifest.Layers %d) }}", i), encryptImageRef).AssertOutContains("org.opencontainers.image.enc.keys.jwe") - } - ipfsCIDEnc := cidOf(t, base.Cmd("push", "ipfs://"+encryptImageRef).OutLines()) - rmiAll(base) - - decryptImageRef := tID + ":dec" - base.Cmd("pull", "--unpack=false", ipfsCIDEnc).AssertOK() - base.Cmd("image", "decrypt", "--key="+keyPair.pub, ipfsCIDEnc, decryptImageRef).AssertFail() // decryption needs prv key, not pub key - base.Cmd("image", "decrypt", "--key="+keyPair.prv, ipfsCIDEnc, decryptImageRef).AssertOK() - base.Cmd("run", "--rm", decryptImageRef, "/bin/sh", "-c", "echo hello").AssertOK() -} - -var iplineRegexp = regexp.MustCompile(`"([0-9\.]*)"`) - -func TestIPFSAddress(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - ipfsaddr, done := runIPFSDaemonContainer(t, base) - defer done() - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage, fmt.Sprintf("--ipfs-address=%s", ipfsaddr)) - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=overlayfs") - base.Cmd("pull", "--ipfs-address", ipfsaddr, ipfsCID).AssertOK() - base.Cmd("run", "--ipfs-address", ipfsaddr, "--rm", ipfsCID, "echo", "hello").AssertOK() -} - -func runIPFSDaemonContainer(t *testing.T, base *testutil.Base) (ipfsAddress string, done func()) { - name := "test-ipfs-address" - base.Cmd("run", "-d", "--name", name, "--entrypoint=/bin/sh", testutil.KuboImage, "-c", "ipfs init && ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001 && ipfs daemon --offline").AssertOK() - iplines := base.Cmd("inspect", name, "-f", "'{{json .NetworkSettings.IPAddress}}'").OutLines() - t.Logf("IPAddress=%v", iplines) - assert.Equal(t, len(iplines), 2) - matches := iplineRegexp.FindStringSubmatch(iplines[0]) - t.Logf("ip address matches=%v", matches) - assert.Equal(t, len(matches), 2) - ipfsaddr := fmt.Sprintf("/ip4/%s/tcp/5001", matches[1]) - return ipfsaddr, func() { - base.Cmd("kill", "test-ipfs-address").AssertOK() - base.Cmd("rm", "test-ipfs-address").AssertOK() - } -} - -func TestIPFSCommit(t *testing.T) { - // cgroup is required for nerdctl commit - if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { - t.Skip("test skipped for rootless containers on cgroup v1") - } - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage) - - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=overlayfs") - base.Cmd("pull", ipfsCID).AssertOK() - base.Cmd("run", "--rm", ipfsCID, "echo", "hello").AssertOK() - tID := testutil.Identifier(t) - newContainer, newImg := tID, tID+":v1" - base.Cmd("run", "--name", newContainer, "-d", ipfsCID, "/bin/sh", "-c", "echo hello > /hello ; sleep 10000").AssertOK() - base.Cmd("commit", newContainer, newImg).AssertOK() - base.Cmd("kill", newContainer).AssertOK() - base.Cmd("rm", newContainer).AssertOK() - ipfsCID2 := cidOf(t, base.Cmd("push", "ipfs://"+newImg).OutLines()) - rmiAll(base) - base.Cmd("pull", ipfsCID2).AssertOK() - base.Cmd("run", "--rm", ipfsCID2, "/bin/sh", "-c", "cat /hello").AssertOK() -} - -func TestIPFSWithLazyPulling(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - requiresStargz(base) - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage, "--estargz") - - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=stargz") - base.Cmd("pull", ipfsCID).AssertOK() - base.Cmd("run", "--rm", ipfsCID, "ls", "/.stargz-snapshotter").AssertOK() -} - -func TestIPFSWithLazyPullingCommit(t *testing.T) { - // cgroup is required for nerdctl commit - if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { - t.Skip("test skipped for rootless containers on cgroup v1") - } - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - requiresStargz(base) - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage, "--estargz") - - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=stargz") - base.Cmd("pull", ipfsCID).AssertOK() - base.Cmd("run", "--rm", ipfsCID, "ls", "/.stargz-snapshotter").AssertOK() - tID := testutil.Identifier(t) - newContainer, newImg := tID, tID+":v1" - base.Cmd("run", "--name", newContainer, "-d", ipfsCID, "/bin/sh", "-c", "echo hello > /hello ; sleep 10000").AssertOK() - base.Cmd("commit", newContainer, newImg).AssertOK() - base.Cmd("kill", newContainer).AssertOK() - base.Cmd("rm", newContainer).AssertOK() - ipfsCID2 := cidOf(t, base.Cmd("push", "--estargz", "ipfs://"+newImg).OutLines()) - rmiAll(base) - - base.Cmd("pull", ipfsCID2).AssertOK() - base.Cmd("run", "--rm", ipfsCID2, "/bin/sh", "-c", "ls /.stargz-snapshotter && cat /hello").AssertOK() - base.Cmd("image", "rm", ipfsCID2).AssertOK() -} - -func pushImageToIPFS(t *testing.T, base *testutil.Base, name string, opts ...string) string { - base.Cmd("pull", name).AssertOK() - ipfsCID := cidOf(t, base.Cmd(append([]string{"push"}, append(opts, "ipfs://"+name)...)...).OutLines()) - base.Cmd("rmi", name).AssertOK() - return ipfsCID -} - -func cidOf(t *testing.T, lines []string) string { - assert.Equal(t, len(lines) >= 2, true) - return "ipfs://" + lines[len(lines)-2] -} diff --git a/cmd/nerdctl/ipfs_registry_linux_test.go b/cmd/nerdctl/ipfs_registry_linux_test.go deleted file mode 100644 index cec59e59756..00000000000 --- a/cmd/nerdctl/ipfs_registry_linux_test.go +++ /dev/null @@ -1,60 +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 main - -import ( - "os" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestIPFSRegistry(t *testing.T) { - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=overlayfs") - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage) - ipfsRegistryAddr := "localhost:5555" - ipfsRegistryRef := ipfsRegistryReference(ipfsRegistryAddr, ipfsCID) - - done := ipfsRegistryUp(t, base, "--listen-registry", ipfsRegistryAddr) - defer done() - base.Cmd("pull", ipfsRegistryRef).AssertOK() - base.Cmd("run", "--rm", ipfsRegistryRef, "echo", "hello").AssertOK() -} - -func TestIPFSRegistryWithLazyPulling(t *testing.T) { - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - requiresStargz(base) - base.Env = append(os.Environ(), "CONTAINERD_SNAPSHOTTER=stargz") - ipfsCID := pushImageToIPFS(t, base, testutil.AlpineImage, "--estargz") - ipfsRegistryAddr := "localhost:5555" - ipfsRegistryRef := ipfsRegistryReference(ipfsRegistryAddr, ipfsCID) - - done := ipfsRegistryUp(t, base, "--listen-registry", ipfsRegistryAddr) - defer done() - base.Cmd("pull", ipfsRegistryRef).AssertOK() - base.Cmd("run", "--rm", ipfsRegistryRef, "ls", "/.stargz-snapshotter").AssertOK() -} - -func ipfsRegistryReference(addr string, c string) string { - return addr + "/ipfs/" + strings.TrimPrefix(c, "ipfs://") -} diff --git a/cmd/nerdctl/issues/issues_linux_test.go b/cmd/nerdctl/issues/issues_linux_test.go new file mode 100644 index 00000000000..3f9e89ffd89 --- /dev/null +++ b/cmd/nerdctl/issues/issues_linux_test.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. +*/ + +// Package issues is meant to document testing for complex scenarios type of issues that cannot simply be ascribed +// to a specific package. +package issues + +import ( + "fmt" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestIssue3425(t *testing.T) { + nerdtest.Setup() + + var reg *registry.Server + + testCase := &test.Case{ + Require: nerdtest.Registry, + Setup: func(data test.Data, helpers test.Helpers) { + reg = nerdtest.RegistryWithNoAuth(data, helpers, 0, false) + reg.Setup(data, helpers) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if reg != nil { + reg.Cleanup(data, helpers) + } + }, + SubTests: []*test.Case{ + { + Description: "with tag", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage) + helpers.Ensure("image", "rm", "-f", testutil.CommonImage) + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("tag", testutil.CommonImage, fmt.Sprintf("localhost:%d/%s", reg.Port, identifier)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("rm", "-f", identifier) + helpers.Anyhow("rmi", "-f", fmt.Sprintf("localhost:%d/%s", reg.Port, identifier)) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", fmt.Sprintf("localhost:%d/%s", reg.Port, data.Identifier())) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with commit", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "touch", "/something") + helpers.Ensure("image", "rm", "-f", testutil.CommonImage) + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("commit", identifier, fmt.Sprintf("localhost:%d/%s", reg.Port, identifier)) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", fmt.Sprintf("localhost:%d/%s", reg.Port, data.Identifier())) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("push", fmt.Sprintf("localhost:%d/%s", reg.Port, data.Identifier())) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with save", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage) + helpers.Ensure("image", "rm", "-f", testutil.CommonImage) + helpers.Ensure("image", "pull", 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("save", testutil.CommonImage) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with convert", + Require: test.Require( + nerdtest.Private, + test.Not(test.Windows), + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage) + helpers.Ensure("image", "rm", "-f", testutil.CommonImage) + helpers.Ensure("image", "pull", testutil.CommonImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "convert", "--oci", "--estargz", testutil.CommonImage, data.Identifier()) + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "with ipfs", + Require: test.Require( + nerdtest.Private, + nerdtest.IPFS, + test.Not(test.Windows), + test.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("image", "pull", testutil.CommonImage) + helpers.Ensure("run", "-d", "--name", data.Identifier(), testutil.CommonImage) + helpers.Ensure("image", "rm", "-f", testutil.CommonImage) + helpers.Ensure("image", "pull", testutil.CommonImage) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "push", "ipfs://"+testutil.CommonImage) + }, + Expected: test.Expects(0, nil, nil), + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/issues/main_linux_test.go b/cmd/nerdctl/issues/main_linux_test.go new file mode 100644 index 00000000000..a1af21d8ff5 --- /dev/null +++ b/cmd/nerdctl/issues/main_linux_test.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 issues + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} + +// TestIssue108 tests https://github.com/containerd/nerdctl/issues/108 +// ("`nerdctl run --net=host -it` fails while `nerdctl run -it --net=host` works") +func TestIssue108(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "-it --net=host", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("run", "-it", "--rm", "--net=host", testutil.CommonImage, "echo", "this was always working") + cmd.WithPseudoTTY() + return cmd + }, + Expected: test.Expects(0, nil, test.Equals("this was always working\r\n")), + }, + { + Description: "--net=host -it", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("run", "--rm", "--net=host", "-it", testutil.CommonImage, "echo", "this was not working due to issue #108") + cmd.WithPseudoTTY() + return cmd + }, + Expected: test.Expects(0, nil, test.Equals("this was not working due to issue #108\r\n")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/login.go b/cmd/nerdctl/login.go deleted file mode 100644 index adb7d31b182..00000000000 --- a/cmd/nerdctl/login.go +++ /dev/null @@ -1,99 +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 main - -import ( - "errors" - "io" - "strings" - - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/login" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -type loginOptions struct { - serverAddress string - username string - password string - passwordStdin bool -} - -var options = new(loginOptions) - -func newLoginCommand() *cobra.Command { - var loginCommand = &cobra.Command{ - Use: "login [flags] [SERVER]", - Args: cobra.MaximumNArgs(1), - Short: "Log in to a container registry", - RunE: loginAction, - SilenceUsage: true, - SilenceErrors: true, - } - loginCommand.Flags().StringVarP(&options.username, "username", "u", "", "Username") - loginCommand.Flags().StringVarP(&options.password, "password", "p", "", "Password") - loginCommand.Flags().BoolVar(&options.passwordStdin, "password-stdin", false, "Take the password from stdin") - return loginCommand -} - -func loginAction(cmd *cobra.Command, args []string) error { - if len(args) == 1 { - options.serverAddress = args[0] - } - if err := verifyLoginOptions(cmd, options); err != nil { - return err - } - - globalOptions, err := processRootCmdFlags(cmd) - if err != nil { - return err - } - - return login.Login(cmd.Context(), types.LoginCommandOptions{ - GOptions: globalOptions, - ServerAddress: options.serverAddress, - Username: options.username, - Password: options.password, - }, cmd.OutOrStdout()) -} - -// copied from github.com/docker/cli/cli/command/registry/login.go (v20.10.3) -func verifyLoginOptions(cmd *cobra.Command, options *loginOptions) error { - if options.password != "" { - logrus.Warn("WARNING! Using --password via the CLI is insecure. Use --password-stdin.") - if options.passwordStdin { - return errors.New("--password and --password-stdin are mutually exclusive") - } - } - - if options.passwordStdin { - if options.username == "" { - return errors.New("must provide --username with --password-stdin") - } - - contents, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return err - } - - options.password = strings.TrimSuffix(string(contents), "\n") - options.password = strings.TrimSuffix(options.password, "\r") - } - return nil -} diff --git a/cmd/nerdctl/login/login.go b/cmd/nerdctl/login/login.go new file mode 100644 index 00000000000..64dc5f4b265 --- /dev/null +++ b/cmd/nerdctl/login/login.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 login + +import ( + "errors" + "io" + "strings" + + "github.com/spf13/cobra" + + "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/cmd/login" +) + +func NewLoginCommand() *cobra.Command { + var loginCommand = &cobra.Command{ + Use: "login [flags] [SERVER]", + Args: cobra.MaximumNArgs(1), + Short: "Log in to a container registry", + RunE: loginAction, + SilenceUsage: true, + SilenceErrors: true, + } + loginCommand.Flags().StringP("username", "u", "", "Username") + loginCommand.Flags().StringP("password", "p", "", "Password") + loginCommand.Flags().Bool("password-stdin", false, "Take the password from stdin") + return loginCommand +} + +func processLoginOptions(cmd *cobra.Command) (types.LoginCommandOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.LoginCommandOptions{}, err + } + + username, err := cmd.Flags().GetString("username") + if err != nil { + return types.LoginCommandOptions{}, err + } + password, err := cmd.Flags().GetString("password") + if err != nil { + return types.LoginCommandOptions{}, err + } + passwordStdin, err := cmd.Flags().GetBool("password-stdin") + if err != nil { + return types.LoginCommandOptions{}, err + } + + if strings.Contains(username, ":") { + return types.LoginCommandOptions{}, errors.New("username cannot contain colons") + } + + if password != "" { + log.L.Warn("WARNING! Using --password via the CLI is insecure. Use --password-stdin.") + if passwordStdin { + return types.LoginCommandOptions{}, errors.New("--password and --password-stdin are mutually exclusive") + } + } + + if passwordStdin { + if username == "" { + return types.LoginCommandOptions{}, errors.New("must provide --username with --password-stdin") + } + + contents, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return types.LoginCommandOptions{}, err + } + + password = strings.TrimSuffix(string(contents), "\n") + password = strings.TrimSuffix(password, "\r") + } + return types.LoginCommandOptions{ + GOptions: globalOptions, + Username: username, + Password: password, + }, nil +} + +func loginAction(cmd *cobra.Command, args []string) error { + options, err := processLoginOptions(cmd) + if err != nil { + return err + } + + if len(args) == 1 { + options.ServerAddress = args[0] + } + + return login.Login(cmd.Context(), options, cmd.OutOrStdout()) +} diff --git a/cmd/nerdctl/login/login_linux_test.go b/cmd/nerdctl/login/login_linux_test.go new file mode 100644 index 00000000000..2ac21fa374f --- /dev/null +++ b/cmd/nerdctl/login/login_linux_test.go @@ -0,0 +1,548 @@ +/* + 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. +*/ + +// https://docs.docker.com/reference/cli/dockerd/#insecure-registries +// Local registries, whose IP address falls in the 127.0.0.0/8 range, are automatically marked as insecure as of Docker 1.3.2. +// It isn't recommended to rely on this, as it may change in the future. +// "--insecure" means that either the certificates are untrusted, or that the protocol is plain http +package login + +import ( + "fmt" + "net" + "os" + "strconv" + "testing" + + "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" + "github.com/containerd/nerdctl/v2/pkg/testutil/testca" + "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" +) + +type Client struct { + args []string + configPath string +} + +func (ag *Client) WithInsecure(value bool) *Client { + ag.args = append(ag.args, "--insecure-registry="+strconv.FormatBool(value)) + return ag +} + +func (ag *Client) WithHostsDir(hostDirs string) *Client { + ag.args = append(ag.args, "--hosts-dir", hostDirs) + return ag +} + +func (ag *Client) WithCredentials(username, password string) *Client { + if username != "" { + ag.args = append(ag.args, "--username", username) + } + if password != "" { + ag.args = append(ag.args, "--password", password) + } + return ag +} + +func (ag *Client) WithConfigPath(value string) *Client { + ag.configPath = value + return ag +} + +func (ag *Client) GetConfigPath() string { + return ag.configPath +} + +func (ag *Client) Run(base *testutil.Base, host string) *testutil.Cmd { + if ag.configPath == "" { + ag.configPath, _ = os.MkdirTemp(base.T.TempDir(), "docker-config") + } + args := []string{"login"} + if base.Target == "nerdctl" { + args = append(args, "--debug-full") + } + args = append(args, ag.args...) + icmdCmd := icmd.Command(base.Binary, append(base.Args, append(args, host)...)...) + icmdCmd.Env = append(base.Env, "HOME="+os.Getenv("HOME"), "DOCKER_CONFIG="+ag.configPath) + + return &testutil.Cmd{ + Cmd: icmdCmd, + Base: base, + } +} + +func TestLoginPersistence(t *testing.T) { + base := testutil.NewBase(t) + t.Parallel() + + // Retrieve from the store + testCases := []struct { + auth string + }{ + { + "basic", + }, + { + "token", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("Server %s", tc.auth), func(t *testing.T) { + t.Parallel() + + username := test.RandomStringBase64(30) + "∞" + password := test.RandomStringBase64(30) + ":∞" + + // Add the requested authentication + var auth testregistry.Auth + var dependentCleanup func(error) + + auth = &testregistry.NoAuth{} + if tc.auth == "basic" { + auth = &testregistry.BasicAuth{ + Username: username, + Password: password, + } + } else if tc.auth == "token" { + authCa := testca.New(base.T) + as := testregistry.NewAuthServer(base, authCa, 0, username, password, false) + auth = &testregistry.TokenAuth{ + Address: as.Scheme + "://" + net.JoinHostPort(as.IP.String(), strconv.Itoa(as.Port)), + CertPath: as.CertPath, + } + dependentCleanup = as.Cleanup + } + + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, nil, 0, auth, dependentCleanup) + + // Register registry cleanup + t.Cleanup(func() { + reg.Cleanup(nil) + }) + + // First, login successfully + c := (&Client{}). + WithCredentials(username, password) + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + // Now, log in successfully without passing any explicit credentials + nc := (&Client{}). + WithConfigPath(c.GetConfigPath()) + nc.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + // Now fail while using invalid credentials + nc.WithCredentials("invalid", "invalid"). + Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertFail() + + // And login again without, reverting to the last saved good state + nc = (&Client{}). + WithConfigPath(c.GetConfigPath()) + + nc.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + }) + } +} + +/* +func TestAgainstNoAuth(t *testing.T) { + base := testutil.NewBase(t) + t.Parallel() + + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, nil, 0, &testregistry.NoAuth{}, nil) + + // Register registry cleanup + t.Cleanup(func() { + reg.Cleanup(nil) + }) + + c := (&Client{}). + WithCredentials("invalid", "invalid") + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + content, _ := os.ReadFile(filepath.Join(c.configPath, "config.json")) + fmt.Println(string(content)) + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertFail() + +} + +*/ + +func TestLoginAgainstVariants(t *testing.T) { + // Skip docker, because Docker doesn't have `--hosts-dir` nor `insecure-registry` option + // This will test access to a wide variety of servers, with or without TLS, with basic or token authentication + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + t.Parallel() + + testCases := []struct { + port int + tls bool + auth string + }{ + // Basic auth, no TLS + { + 80, + false, + "basic", + }, + { + 443, + false, + "basic", + }, + { + 0, + false, + "basic", + }, + // Token auth, no TLS + { + 80, + false, + "token", + }, + { + 443, + false, + "token", + }, + { + 0, + false, + "token", + }, + // Basic auth, with TLS + /* + // This is not working currently, unless we would force a server https:// in hosts + // To be fixed with login rewrite + { + 80, + true, + "basic", + }, + */ + { + 443, + true, + "basic", + }, + { + 0, + true, + "basic", + }, + // Token auth, with TLS + /* + // This is not working currently, unless we would force a server https:// in hosts + // To be fixed with login rewrite + { + 80, + true, + "token", + }, + */ + { + 443, + true, + "token", + }, + { + 0, + true, + "token", + }, + } + + // Iterate through all cases, that will present a variety of port (80, 443, random), TLS (yes or no), and authentication (basic, token) type combinations + for _, tc := range testCases { + port := tc.port + tls := tc.tls + auth := tc.auth + + t.Run(fmt.Sprintf("Login against `tls: %t port: %d auth: %s`", tls, port, auth), func(t *testing.T) { + // Tests with fixed ports should not be parallelized (although the port locking mechanism will prevent conflicts) + // as their children tests are parallelized, and this might deadlock given the way `Parallel` works + if port == 0 { + t.Parallel() + } + + // Generate credentials that are specific to each registry, so that we never cross hit another one + username := test.RandomStringBase64(30) + "∞" + password := test.RandomStringBase64(30) + ":∞" + + // Get a CA if we want TLS + var ca *testca.CA + if tls { + ca = testca.New(base.T) + } + + // Add the requested authenticator + var authenticator testregistry.Auth + var dependentCleanup func(error) + + authenticator = &testregistry.NoAuth{} + if auth == "basic" { + authenticator = &testregistry.BasicAuth{ + Username: username, + Password: password, + } + } else if auth == "token" { + authCa := ca + // We could be on !tls, meaning no ca - but we still need a CA to sign jwt tokens + if authCa == nil { + authCa = testca.New(base.T) + } + as := testregistry.NewAuthServer(base, authCa, 0, username, password, tls) + authenticator = &testregistry.TokenAuth{ + Address: as.Scheme + "://" + net.JoinHostPort(as.IP.String(), strconv.Itoa(as.Port)), + CertPath: as.CertPath, + } + dependentCleanup = as.Cleanup + } + + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, ca, port, authenticator, dependentCleanup) + + // Register registry cleanup + t.Cleanup(func() { + reg.Cleanup(nil) + }) + + // Any registry is reachable through its ip+port, and localhost variants + regHosts := []string{ + net.JoinHostPort(reg.IP.String(), strconv.Itoa(reg.Port)), + net.JoinHostPort("localhost", strconv.Itoa(reg.Port)), + net.JoinHostPort("127.0.0.1", strconv.Itoa(reg.Port)), + // TODO: ipv6 + // net.JoinHostPort("::1", strconv.Itoa(reg.Port)), + } + + // Registries that use port 443 also allow access without specifying a port + if reg.Port == 443 { + regHosts = append(regHosts, reg.IP.String()) + regHosts = append(regHosts, "localhost") + regHosts = append(regHosts, "127.0.0.1") + // TODO: ipv6 + // regHosts = append(regHosts, "::1") + } + + // Iterate through these hosts access points, and create a test per-variant + for _, value := range regHosts { + regHost := value + t.Run(regHost, func(t *testing.T) { + t.Parallel() + + // 1. test with valid credentials but no access to the CA + t.Run("1. valid credentials (no CA) ", func(t *testing.T) { + t.Parallel() + + c := (&Client{}). + WithCredentials(username, password) + + rl, _ := dockerconfigresolver.Parse(regHost) + // a. Insecure flag not being set + // TODO: remove specialization when we fix the localhost mess + if rl.IsLocalhost() && !tls { + c.Run(base, regHost). + AssertOK() + } else { + c.Run(base, regHost). + AssertFail() + } + + // b. Insecure flag set to false + // TODO: remove specialization when we fix the localhost mess + if !rl.IsLocalhost() { + (&Client{}). + WithCredentials(username, password). + WithInsecure(false). + Run(base, regHost). + AssertFail() + } + + // c. Insecure flag set to true + // TODO: remove specialization when we fix the localhost mess + if !rl.IsLocalhost() || !tls { + (&Client{}). + WithCredentials(username, password). + WithInsecure(true). + Run(base, regHost). + AssertOK() + } + }) + + // 2. test with valid credentials AND access to the CA + t.Run("2. valid credentials (with access to server CA)", func(t *testing.T) { + t.Parallel() + + rl, _ := dockerconfigresolver.Parse(regHost) + + // a. Insecure flag not being set + c := (&Client{}). + WithCredentials(username, password). + WithHostsDir(reg.HostsDir) + + if tls || rl.IsLocalhost() { + c.Run(base, regHost). + AssertOK() + } else { + c.Run(base, regHost). + AssertFail() + } + + // b. Insecure flag set to false + if tls { + c.WithInsecure(false). + Run(base, regHost). + AssertOK() + } else { + // TODO: remove specialization when we fix the localhost mess + if !rl.IsLocalhost() { + c.WithInsecure(false). + Run(base, regHost). + AssertFail() + } + } + + // c. Insecure flag set to true + c.WithInsecure(true). + Run(base, regHost). + AssertOK() + }) + + t.Run("3. valid credentials, any url variant, should always succeed", func(t *testing.T) { + t.Parallel() + c := (&Client{}). + WithCredentials(username, password). + WithHostsDir(reg.HostsDir). + // Just use insecure here for all servers - it does not matter for what we are testing here + // in this case, which is whether we can successfully log in against any of these variants + WithInsecure(true) + + // TODO: remove specialization when we fix the localhost mess + rl, _ := dockerconfigresolver.Parse(regHost) + if !rl.IsLocalhost() || !tls { + c.Run(base, "http://"+regHost).AssertOK() + c.Run(base, "https://"+regHost).AssertOK() + c.Run(base, "http://"+regHost+"/whatever?foo=bar;foo:bar#foo=bar").AssertOK() + c.Run(base, "https://"+regHost+"/whatever?foo=bar&bar=foo;foo=foo+bar:bar#foo=bar").AssertOK() + } + }) + + t.Run("4. wrong password should always fail", func(t *testing.T) { + t.Parallel() + + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + WithInsecure(true). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + WithInsecure(true). + Run(base, regHost). + AssertFail() + }) + + t.Run("5. wrong username should always fail", func(t *testing.T) { + t.Parallel() + + (&Client{}). + WithCredentials("invalid", password). + WithHostsDir(reg.HostsDir). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + WithHostsDir(reg.HostsDir). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + WithHostsDir(reg.HostsDir). + WithInsecure(true). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + WithInsecure(true). + Run(base, regHost). + AssertFail() + }) + }) + } + }) + } +} diff --git a/cmd/nerdctl/login/login_test.go b/cmd/nerdctl/login/login_test.go new file mode 100644 index 00000000000..ca5ad784c9b --- /dev/null +++ b/cmd/nerdctl/login/login_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 login + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/login/logout.go b/cmd/nerdctl/login/logout.go new file mode 100644 index 00000000000..f43643b791f --- /dev/null +++ b/cmd/nerdctl/login/logout.go @@ -0,0 +1,66 @@ +/* + 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 login + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/cmd/logout" +) + +func NewLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout [flags] [SERVER]", + Args: cobra.MaximumNArgs(1), + Short: "Log out from a container registry", + RunE: logoutAction, + ValidArgsFunction: logoutShellComplete, + SilenceUsage: true, + SilenceErrors: true, + } +} + +func logoutAction(cmd *cobra.Command, args []string) error { + logoutServer := "" + if len(args) > 0 { + logoutServer = args[0] + } + + errGroup, err := logout.Logout(cmd.Context(), logoutServer) + if err != nil { + log.L.WithError(err).Errorf("Failed to erase credentials for: %s", logoutServer) + } + if errGroup != nil { + log.L.Error("None of the following entries could be found") + for _, v := range errGroup { + log.L.Errorf("%s", v) + } + } + + return err +} + +func logoutShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + candidates, err := logout.ShellCompletion() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return candidates, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/nerdctl/login_linux_test.go b/cmd/nerdctl/login_linux_test.go deleted file mode 100644 index 12fbc8d4377..00000000000 --- a/cmd/nerdctl/login_linux_test.go +++ /dev/null @@ -1,191 +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 main - -import ( - "fmt" - "net" - "path" - "strconv" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/testregistry" -) - -func TestLogin(t *testing.T) { - // Skip docker, because Docker doesn't have `--hosts-dir` option, and we don't want to contaminate the global /etc/docker/certs.d during this test - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewHTTPS(base, "admin", "validTestPassword") - defer reg.Cleanup() - - regHost := net.JoinHostPort(reg.IP.String(), strconv.Itoa(reg.ListenPort)) - - t.Logf("Good password") - base.Cmd("--debug-full", "--hosts-dir", reg.HostsDir, "login", "-u", "admin", "-p", "validTestPassword", regHost).AssertOK() - - t.Logf("Bad password") - base.Cmd("--debug-full", "--hosts-dir", reg.HostsDir, "login", "-u", "admin", "-p", "invalidTestPassword", regHost).AssertFail() -} - -func TestLoginWithSpecificRegHosts(t *testing.T) { - // Skip docker, because Docker doesn't have `--hosts-dir` option, and we don't want to contaminate the global /etc/docker/certs.d during this test - testutil.DockerIncompatible(t) - - base := testutil.NewBase(t) - reg := testregistry.NewHTTPS(base, "admin", "validTestPassword") - defer reg.Cleanup() - - regHost := net.JoinHostPort(reg.IP.String(), strconv.Itoa(reg.ListenPort)) - - t.Logf("Prepare regHost URL with path and Scheme") - - type testCase struct { - url string - log string - } - testCases := []testCase{ - { - url: "https://" + path.Join(regHost, "test"), - log: "Login with repository containing path and scheme in the URL", - }, - { - url: path.Join(regHost, "test"), - log: "Login with repository containing path and without scheme in the URL", - }, - } - for _, tc := range testCases { - t.Logf(tc.log) - base.Cmd("--debug-full", "--hosts-dir", reg.HostsDir, "login", "-u", "admin", "-p", "validTestPassword", tc.url).AssertOK() - } - -} - -func TestLoginWithPlainHttp(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - reg5000 := testregistry.NewAuthWithHTTP(base, "admin", "validTestPassword", 5000, 5001) - reg80 := testregistry.NewAuthWithHTTP(base, "admin", "validTestPassword", 80, 5002) - defer reg5000.Cleanup() - defer reg80.Cleanup() - testCasesForPort5000 := []struct { - regHost string - regPort int - useRegPort bool - username string - password string - shouldSuccess bool - registry *testregistry.TestRegistry - shouldUseInSecure bool - }{ - { - regHost: "127.0.0.1", - regPort: 5000, - useRegPort: true, - username: "admin", - password: "validTestPassword", - shouldSuccess: true, - registry: reg5000, - shouldUseInSecure: true, - }, - { - regHost: "127.0.0.1", - regPort: 5000, - useRegPort: true, - username: "admin", - password: "invalidTestPassword", - shouldSuccess: false, - registry: reg5000, - shouldUseInSecure: true, - }, - { - regHost: "127.0.0.1", - regPort: 5000, - useRegPort: true, - username: "admin", - password: "validTestPassword", - // Following the merging of the below, any localhost/loopback registries will - // get automatically downgraded to HTTP so this will still succceed: - // https://github.com/containerd/containerd/pull/7393 - shouldSuccess: true, - registry: reg5000, - shouldUseInSecure: false, - }, - { - regHost: "127.0.0.1", - regPort: 80, - useRegPort: false, - username: "admin", - password: "validTestPassword", - shouldSuccess: true, - registry: reg80, - shouldUseInSecure: true, - }, - { - regHost: "127.0.0.1", - regPort: 80, - useRegPort: false, - username: "admin", - password: "invalidTestPassword", - shouldSuccess: false, - registry: reg80, - shouldUseInSecure: true, - }, - { - regHost: "127.0.0.1", - regPort: 80, - useRegPort: false, - username: "admin", - password: "validTestPassword", - // Following the merging of the below, any localhost/loopback registries will - // get automatically downgraded to HTTP so this will still succceed: - // https://github.com/containerd/containerd/pull/7393 - shouldSuccess: true, - registry: reg80, - shouldUseInSecure: false, - }, - } - for _, tc := range testCasesForPort5000 { - tcName := fmt.Sprintf("%+v", tc) - t.Run(tcName, func(t *testing.T) { - regHost := tc.regHost - if tc.useRegPort { - regHost = fmt.Sprintf("%s:%d", regHost, tc.regPort) - } - if tc.shouldSuccess { - t.Logf("Good password") - } else { - t.Logf("Bad password") - } - var args []string - if tc.shouldUseInSecure { - args = append(args, "--insecure-registry") - } - args = append(args, []string{ - "--debug-full", "--hosts-dir", tc.registry.HostsDir, "login", "-u", tc.username, "-p", tc.password, regHost, - }...) - cmd := base.Cmd(args...) - if tc.shouldSuccess { - cmd.AssertOK() - } else { - cmd.AssertFail() - } - }) - } -} diff --git a/cmd/nerdctl/logout.go b/cmd/nerdctl/logout.go deleted file mode 100644 index 09b4ee43179..00000000000 --- a/cmd/nerdctl/logout.go +++ /dev/null @@ -1,95 +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 main - -import ( - "fmt" - - "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" - dockercliconfig "github.com/docker/cli/cli/config" - "github.com/spf13/cobra" -) - -func newLogoutCommand() *cobra.Command { - var logoutCommand = &cobra.Command{ - Use: "logout [flags] [SERVER]", - Args: cobra.MaximumNArgs(1), - Short: "Log out from a container registry", - RunE: logoutAction, - ValidArgsFunction: logoutShellComplete, - SilenceUsage: true, - SilenceErrors: true, - } - return logoutCommand -} - -// code inspired from XXX -func logoutAction(cmd *cobra.Command, args []string) error { - serverAddress := dockerconfigresolver.IndexServer - isDefaultRegistry := true - if len(args) >= 1 { - serverAddress = args[0] - isDefaultRegistry = false - } - - var ( - regsToLogout = []string{serverAddress} - hostnameAddress = serverAddress - ) - - if !isDefaultRegistry { - hostnameAddress = dockerconfigresolver.ConvertToHostname(serverAddress) - // the tries below are kept for backward compatibility where a user could have - // saved the registry in one of the following format. - regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress) - } - - fmt.Fprintf(cmd.OutOrStdout(), "Removing login credentials for %s\n", hostnameAddress) - - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return err - } - errs := make(map[string]error) - for _, r := range regsToLogout { - if err := dockerConfigFile.GetCredentialsStore(r).Erase(r); err != nil { - errs[r] = err - } - } - - // if at least one removal succeeded, report success. Otherwise report errors - if len(errs) == len(regsToLogout) { - fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: could not erase credentials:") - for k, v := range errs { - fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", k, v) - } - } - - return nil -} - -func logoutShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - candidates := []string{} - for key := range dockerConfigFile.AuthConfigs { - candidates = append(candidates, key) - } - return candidates, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 6113790c8e0..36ef2fc079f 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -21,27 +21,36 @@ import ( "fmt" "os" "runtime" - "strconv" "strings" - "time" - - "github.com/containerd/nerdctl/pkg/config" - ncdefaults "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/logging" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/version" - "github.com/fatih/color" - "github.com/pelletier/go-toml" - "github.com/sirupsen/logrus" + "github.com/fatih/color" + "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" -) -const ( - Category = "category" - Management = "management" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/builder" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/compose" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/container" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/image" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/inspect" + "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/namespace" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/network" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/system" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/volume" + "github.com/containerd/nerdctl/v2/pkg/config" + ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/logging" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/store" + "github.com/containerd/nerdctl/v2/pkg/version" ) var ( @@ -72,7 +81,7 @@ func usage(c *cobra.Command) error { if f.Hidden { continue } - if f.Annotations[Category] == Management { + if f.Annotations[helpers.Category] == helpers.Management { managementCommands = append(managementCommands, f) } else { nonManagementCommands = append(nonManagementCommands, f) @@ -100,7 +109,7 @@ func usage(c *cobra.Command) error { t += "\n" return t } - s += printCommands("Management commands", managementCommands) + s += printCommands("helpers.Management commands", managementCommands) s += printCommands("Commands", nonManagementCommands) s += Bold("Flags") + ":\n" @@ -118,7 +127,7 @@ func usage(c *cobra.Command) error { func main() { if err := xmain(); err != nil { errutil.HandleExitCoder(err) - logrus.Fatal(err) + log.L.Fatal(err) } } @@ -139,15 +148,15 @@ func xmain() error { func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, error) { cfg := config.New() if r, err := os.Open(tomlPath); err == nil { - logrus.Debugf("Loading config from %q", tomlPath) + log.L.Debugf("Loading config from %q", tomlPath) defer r.Close() - dec := toml.NewDecoder(r).Strict(true) // set Strict to detect typo + dec := toml.NewDecoder(r).DisallowUnknownFields() // set Strict to detect typo if err := dec.Decode(cfg); err != nil { return nil, fmt.Errorf("failed to load nerdctl config (not daemon config) from %q (Hint: don't mix up daemon's `config.toml` with `nerdctl.toml`): %w", tomlPath, err) } - logrus.Debugf("Loaded config %+v", cfg) + log.L.Debugf("Loaded config %+v", cfg) } else { - logrus.WithError(err).Debugf("Not loading config from %q", tomlPath) + log.L.WithError(err).Debugf("Not loading config from %q", tomlPath) if !errors.Is(err, os.ErrNotExist) { return nil, err } @@ -157,24 +166,26 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, rootCmd.PersistentFlags().Bool("debug", cfg.Debug, "debug mode") rootCmd.PersistentFlags().Bool("debug-full", cfg.DebugFull, "debug mode (with full output)") // -a is aliases (conflicts with nerdctl images -a) - AddPersistentStringFlag(rootCmd, "address", []string{"a", "H"}, nil, []string{"host"}, aliasToBeInherited, cfg.Address, "CONTAINERD_ADDRESS", `containerd address, optionally with "unix://" prefix`) + helpers.AddPersistentStringFlag(rootCmd, "address", []string{"a", "H"}, nil, []string{"host"}, aliasToBeInherited, cfg.Address, "CONTAINERD_ADDRESS", `containerd address, optionally with "unix://" prefix`) // -n is aliases (conflicts with nerdctl logs -n) - AddPersistentStringFlag(rootCmd, "namespace", []string{"n"}, nil, nil, aliasToBeInherited, cfg.Namespace, "CONTAINERD_NAMESPACE", `containerd namespace, such as "moby" for Docker, "k8s.io" for Kubernetes`) - rootCmd.RegisterFlagCompletionFunc("namespace", shellCompleteNamespaceNames) - AddPersistentStringFlag(rootCmd, "snapshotter", nil, nil, []string{"storage-driver"}, aliasToBeInherited, cfg.Snapshotter, "CONTAINERD_SNAPSHOTTER", "containerd snapshotter") - rootCmd.RegisterFlagCompletionFunc("snapshotter", shellCompleteSnapshotterNames) - rootCmd.RegisterFlagCompletionFunc("storage-driver", shellCompleteSnapshotterNames) - AddPersistentStringFlag(rootCmd, "cni-path", nil, nil, nil, aliasToBeInherited, cfg.CNIPath, "CNI_PATH", "cni plugins binary directory") - AddPersistentStringFlag(rootCmd, "cni-netconfpath", nil, nil, nil, aliasToBeInherited, cfg.CNINetConfPath, "NETCONFPATH", "cni config directory") + helpers.AddPersistentStringFlag(rootCmd, "namespace", []string{"n"}, nil, nil, aliasToBeInherited, cfg.Namespace, "CONTAINERD_NAMESPACE", `containerd namespace, such as "moby" for Docker, "k8s.io" for Kubernetes`) + rootCmd.RegisterFlagCompletionFunc("namespace", completion.NamespaceNames) + helpers.AddPersistentStringFlag(rootCmd, "snapshotter", nil, nil, []string{"storage-driver"}, aliasToBeInherited, cfg.Snapshotter, "CONTAINERD_SNAPSHOTTER", "containerd snapshotter") + rootCmd.RegisterFlagCompletionFunc("snapshotter", completion.SnapshotterNames) + rootCmd.RegisterFlagCompletionFunc("storage-driver", completion.SnapshotterNames) + helpers.AddPersistentStringFlag(rootCmd, "cni-path", nil, nil, nil, aliasToBeInherited, cfg.CNIPath, "CNI_PATH", "cni plugins binary directory") + helpers.AddPersistentStringFlag(rootCmd, "cni-netconfpath", nil, nil, nil, aliasToBeInherited, cfg.CNINetConfPath, "NETCONFPATH", "cni config directory") rootCmd.PersistentFlags().String("data-root", cfg.DataRoot, "Root directory of persistent nerdctl state (managed by nerdctl, not by containerd)") rootCmd.PersistentFlags().String("cgroup-manager", cfg.CgroupManager, `Cgroup manager to use ("cgroupfs"|"systemd")`) - rootCmd.RegisterFlagCompletionFunc("cgroup-manager", shellCompleteCgroupManagerNames) + rootCmd.RegisterFlagCompletionFunc("cgroup-manager", completion.CgroupManagerNames) rootCmd.PersistentFlags().Bool("insecure-registry", cfg.InsecureRegistry, "skips verifying HTTPS certs, and allows falling back to plain HTTP") // hosts-dir is defined as StringSlice, not StringArray, to allow specifying "--hosts-dir=/etc/containerd/certs.d,/etc/docker/certs.d" rootCmd.PersistentFlags().StringSlice("hosts-dir", cfg.HostsDir, "A directory that contains /hosts.toml (containerd style) or /{ca.cert, cert.pem, key.pem} (docker style)") // Experimental enable experimental feature, see in https://github.com/containerd/nerdctl/blob/main/docs/experimental.md - AddPersistentBoolFlag(rootCmd, "experimental", nil, nil, cfg.Experimental, "NERDCTL_EXPERIMENTAL", "Control experimental: https://github.com/containerd/nerdctl/blob/main/docs/experimental.md") - AddPersistentStringFlag(rootCmd, "host-gateway-ip", nil, nil, nil, aliasToBeInherited, cfg.HostGatewayIP, "NERDCTL_HOST_GATEWAY_IP", "IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host") + helpers.AddPersistentBoolFlag(rootCmd, "experimental", nil, nil, cfg.Experimental, "NERDCTL_EXPERIMENTAL", "Control experimental: https://github.com/containerd/nerdctl/blob/main/docs/experimental.md") + helpers.AddPersistentStringFlag(rootCmd, "host-gateway-ip", nil, nil, nil, aliasToBeInherited, cfg.HostGatewayIP, "NERDCTL_HOST_GATEWAY_IP", "IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host") + helpers.AddPersistentStringFlag(rootCmd, "bridge-ip", nil, nil, nil, aliasToBeInherited, cfg.BridgeIP, "NERDCTL_BRIDGE_IP", "IP address for the default nerdctl bridge network") + rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io") return aliasToBeInherited, nil } @@ -194,7 +205,7 @@ Config file ($NERDCTL_TOML): %s Use: "nerdctl", Short: short, Long: long, - Version: strings.TrimPrefix(version.Version, "v"), + Version: strings.TrimPrefix(version.GetVersion(), "v"), SilenceUsage: true, SilenceErrors: true, TraverseChildren: true, // required for global short hands like -a, -H, -n @@ -207,7 +218,7 @@ Config file ($NERDCTL_TOML): %s } rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -216,7 +227,7 @@ Config file ($NERDCTL_TOML): %s debug = globalOptions.Debug } if debug { - logrus.SetLevel(logrus.DebugLevel) + log.SetLevel(log.DebugLevel.String()) } address := globalOptions.Address if strings.Contains(address, "://") && !strings.HasPrefix(address, "unix://") { @@ -230,91 +241,103 @@ Config file ($NERDCTL_TOML): %s return fmt.Errorf("invalid cgroup-manager %q (supported values: \"systemd\", \"cgroupfs\", \"none\")", cgroupManager) } } + + // 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. + // 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 { + return err + } if appNeedsRootlessParentMain(cmd, args) { // reexec /proc/self/exe with `nsenter` into RootlessKit namespaces return rootlessutil.ParentMain(globalOptions.HostGatewayIP) } return nil } - rootCmd.RunE = unknownSubcommandAction + rootCmd.RunE = helpers.UnknownSubcommandAction rootCmd.AddCommand( - newCreateCommand(), + container.NewCreateCommand(), // #region Run & Exec - newRunCommand(), - newUpdateCommand(), - newExecCommand(), + container.NewRunCommand(), + container.NewUpdateCommand(), + container.NewExecCommand(), // #endregion // #region Container management - newPsCommand(), - newLogsCommand(), - newPortCommand(), - newStopCommand(), - newStartCommand(), - newRestartCommand(), - newKillCommand(), - newRmCommand(), - newPauseCommand(), - newUnpauseCommand(), - newCommitCommand(), - newWaitCommand(), - newRenameCommand(), + container.NewPsCommand(), + container.NewLogsCommand(), + container.NewPortCommand(), + container.NewStopCommand(), + container.NewStartCommand(), + container.NewDiffCommand(), + container.NewRestartCommand(), + container.NewKillCommand(), + container.NewRmCommand(), + container.NewPauseCommand(), + container.NewUnpauseCommand(), + container.NewCommitCommand(), + container.NewWaitCommand(), + container.NewRenameCommand(), + container.NewAttachCommand(), // #endregion // Build - newBuildCommand(), + builder.NewBuildCommand(), // #region Image management - newImagesCommand(), - newPullCommand(), - newPushCommand(), - newLoadCommand(), - newSaveCommand(), - newTagCommand(), - newRmiCommand(), - newHistoryCommand(), + image.NewImagesCommand(), + image.NewPullCommand(), + image.NewPushCommand(), + image.NewLoadCommand(), + image.NewSaveCommand(), + image.NewTagCommand(), + image.NewRmiCommand(), + image.NewHistoryCommand(), // #endregion // #region System - newEventsCommand(), - newInfoCommand(), + system.NewEventsCommand(), + system.NewInfoCommand(), newVersionCommand(), // #endregion // Inspect - newInspectCommand(), + inspect.NewInspectCommand(), // stats - newTopCommand(), - newStatsCommand(), - - // #region Management - newContainerCommand(), - newImageCommand(), - newNetworkCommand(), - newVolumeCommand(), - newSystemCommand(), - newNamespaceCommand(), - newBuilderCommand(), + container.NewTopCommand(), + container.NewStatsCommand(), + + // #region helpers.Management + container.NewContainerCommand(), + image.NewImageCommand(), + network.NewNetworkCommand(), + volume.NewVolumeCommand(), + system.NewSystemCommand(), + namespace.NewNamespaceCommand(), + builder.NewBuilderCommand(), // #endregion // Internal - newInternalCommand(), + internal.NewInternalCommand(), // login - newLoginCommand(), + login.NewLoginCommand(), // Logout - newLogoutCommand(), + login.NewLogoutCommand(), // Compose - newComposeCommand(), + compose.NewComposeCommand(), // IPFS - newIPFSCommand(), + ipfs.NewIPFSCommand(), ) addApparmorCommand(rootCmd) - addCpCommand(rootCmd) + container.AddCpCommand(rootCmd) // add aliasToBeInherited to subCommand(s) InheritedFlags for _, subCmd := range rootCmd.Commands() { @@ -322,271 +345,3 @@ Config file ($NERDCTL_TOML): %s } return rootCmd, nil } - -func globalFlags(cmd *cobra.Command) (string, []string) { - args0, err := os.Executable() - if err != nil { - logrus.WithError(err).Warnf("cannot call os.Executable(), assuming the executable to be %q", os.Args[0]) - args0 = os.Args[0] - } - if len(os.Args) < 2 { - return args0, nil - } - - rootCmd := cmd.Root() - flagSet := rootCmd.Flags() - args := []string{} - flagSet.VisitAll(func(f *pflag.Flag) { - key := f.Name - val := f.Value.String() - if f.Changed { - args = append(args, "--"+key+"="+val) - } - }) - return args0, args -} - -// unknownSubcommandAction is needed to let `nerdctl system non-existent-command` fail -// https://github.com/containerd/nerdctl/issues/487 -// -// Ideally this should be implemented in Cobra itself. -func unknownSubcommandAction(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return cmd.Help() - } - // The output mimics https://github.com/spf13/cobra/blob/v1.2.1/command.go#L647-L662 - msg := fmt.Sprintf("unknown subcommand %q for %q", args[0], cmd.Name()) - if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { - msg += "\n\nDid you mean this?\n" - for _, s := range suggestions { - msg += fmt.Sprintf("\t%v\n", s) - } - } - return errors.New(msg) -} - -// AddStringFlag is similar to cmd.Flags().String but supports aliases and env var -func AddStringFlag(cmd *cobra.Command, name string, aliases []string, value string, env, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - value = envV - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new(string) - flags := cmd.Flags() - flags.StringVar(p, name, value, usage) - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.StringVarP(p, a, a, value, aliasesUsage) - } else { - flags.StringVar(p, a, value, aliasesUsage) - } - } -} - -// AddIntFlag is similar to cmd.Flags().Int but supports aliases and env var -func AddIntFlag(cmd *cobra.Command, name string, aliases []string, value int, env, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - v, err := strconv.ParseInt(envV, 10, 64) - if err != nil { - logrus.WithError(err).Warnf("Invalid int value for `%s`", env) - } - value = int(v) - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new(int) - flags := cmd.Flags() - flags.IntVar(p, name, value, usage) - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.IntVarP(p, a, a, value, aliasesUsage) - } else { - flags.IntVar(p, a, value, aliasesUsage) - } - } -} - -// AddDurationFlag is similar to cmd.Flags().Duration but supports aliases and env var -func AddDurationFlag(cmd *cobra.Command, name string, aliases []string, value time.Duration, env, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - var err error - value, err = time.ParseDuration(envV) - if err != nil { - logrus.WithError(err).Warnf("Invalid duration value for `%s`", env) - } - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new(time.Duration) - flags := cmd.Flags() - flags.DurationVar(p, name, value, usage) - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.DurationVarP(p, a, a, value, aliasesUsage) - } else { - flags.DurationVar(p, a, value, aliasesUsage) - } - } -} - -// AddPersistentStringFlag is similar to AddStringFlag but persistent. -// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". -func AddPersistentStringFlag(cmd *cobra.Command, name string, aliases, localAliases, persistentAliases []string, aliasToBeInherited *pflag.FlagSet, value string, env, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - value = envV - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new(string) - - // flags is full set of flag(s) - // flags can redefine alias already used in subcommands - flags := cmd.Flags() - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.StringVarP(p, a, a, value, aliasesUsage) - } else { - flags.StringVar(p, a, value, aliasesUsage) - } - // non-persistent flags are not added to the InheritedFlags, so we should add them manually - f := flags.Lookup(a) - aliasToBeInherited.AddFlag(f) - } - - // localFlags are local to the rootCmd - localFlags := cmd.LocalFlags() - for _, a := range localAliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - localFlags.StringVarP(p, a, a, value, aliasesUsage) - } else { - localFlags.StringVar(p, a, value, aliasesUsage) - } - } - - // persistentFlags cannot redefine alias already used in subcommands - persistentFlags := cmd.PersistentFlags() - persistentFlags.StringVar(p, name, value, usage) - for _, a := range persistentAliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - persistentFlags.StringVarP(p, a, a, value, aliasesUsage) - } else { - persistentFlags.StringVar(p, a, value, aliasesUsage) - } - } -} - -// AddPersistentBoolFlag is similar to AddBoolFlag but persistent. -// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". -func AddPersistentBoolFlag(cmd *cobra.Command, name string, aliases, nonPersistentAliases []string, value bool, env, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - var err error - value, err = strconv.ParseBool(envV) - if err != nil { - logrus.WithError(err).Warnf("Invalid boolean value for `%s`", env) - } - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new(bool) - flags := cmd.Flags() - for _, a := range nonPersistentAliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.BoolVarP(p, a, a, value, aliasesUsage) - } else { - flags.BoolVar(p, a, value, aliasesUsage) - } - } - - persistentFlags := cmd.PersistentFlags() - persistentFlags.BoolVar(p, name, value, usage) - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - persistentFlags.BoolVarP(p, a, a, value, aliasesUsage) - } else { - persistentFlags.BoolVar(p, a, value, aliasesUsage) - } - } -} - -// AddPersistentStringArrayFlag is similar to cmd.Flags().StringArray but supports aliases and env var and persistent. -// See https://github.com/spf13/cobra/blob/main/user_guide.md#persistent-flags to learn what is "persistent". -func AddPersistentStringArrayFlag(cmd *cobra.Command, name string, aliases, nonPersistentAliases []string, value []string, env string, usage string) { - if env != "" { - usage = fmt.Sprintf("%s [$%s]", usage, env) - } - if envV, ok := os.LookupEnv(env); ok { - value = []string{envV} - } - aliasesUsage := fmt.Sprintf("Alias of --%s", name) - p := new([]string) - flags := cmd.Flags() - for _, a := range nonPersistentAliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - flags.StringArrayVarP(p, a, a, value, aliasesUsage) - } else { - flags.StringArrayVar(p, a, value, aliasesUsage) - } - } - - persistentFlags := cmd.PersistentFlags() - persistentFlags.StringArrayVar(p, name, value, usage) - for _, a := range aliases { - if len(a) == 1 { - // pflag doesn't support short-only flags, so we have to register long one as well here - persistentFlags.StringArrayVarP(p, a, a, value, aliasesUsage) - } else { - persistentFlags.StringArrayVar(p, a, value, aliasesUsage) - } - } -} - -func checkExperimental(feature string) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) - if err != nil { - return err - } - if !globalOptions.Experimental { - return fmt.Errorf("%s is experimental feature, you should enable experimental config", feature) - } - return nil - } -} - -// IsExactArgs returns an error if there is not the exact number of args -func IsExactArgs(number int) cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - if len(args) == number { - return nil - } - return fmt.Errorf( - "%q requires exactly %d %s.\nSee '%s --help'.\n\nUsage: %s\n\n%s", - cmd.CommandPath(), - number, - "argument(s)", - cmd.CommandPath(), - cmd.UseLine(), - cmd.Short, - ) - } -} diff --git a/cmd/nerdctl/main_freebsd.go b/cmd/nerdctl/main_freebsd.go index 30ed7a9c1ea..391d34cfeed 100644 --- a/cmd/nerdctl/main_freebsd.go +++ b/cmd/nerdctl/main_freebsd.go @@ -24,14 +24,6 @@ func appNeedsRootlessParentMain(cmd *cobra.Command, args []string) bool { return false } -func shellCompleteCgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} - func addApparmorCommand(rootCmd *cobra.Command) { // NOP } - -func addCpCommand(rootCmd *cobra.Command) { - // NOP -} diff --git a/cmd/nerdctl/main_linux.go b/cmd/nerdctl/main_linux.go index a20cb00dbb6..44694688c61 100644 --- a/cmd/nerdctl/main_linux.go +++ b/cmd/nerdctl/main_linux.go @@ -17,10 +17,11 @@ package main import ( - ncdefaults "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/apparmor" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func appNeedsRootlessParentMain(cmd *cobra.Command, args []string) bool { @@ -37,10 +38,10 @@ func appNeedsRootlessParentMain(cmd *cobra.Command, args []string) bool { return true } switch commands[1] { - // completion, login, logout: false, because it shouldn't require the daemon to be running + // completion, login, logout, version: false, because it shouldn't require the daemon to be running // apparmor: false, because it requires the initial mount namespace to access /sys/kernel/security - // cp: false, because it requires the initial mount namespace to inspect file owners - case "", "completion", "login", "logout", "apparmor", "cp": + // cp, compose cp: false, because it requires the initial mount namespace to inspect file owners + case "", "completion", "login", "logout", "apparmor", "cp", "version": return false case "container": if len(commands) < 3 { @@ -50,25 +51,18 @@ func appNeedsRootlessParentMain(cmd *cobra.Command, args []string) bool { case "cp": return false } + case "compose": + if len(commands) < 3 { + return true + } + switch commands[2] { + case "cp": + return false + } } return true } -func shellCompleteCgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - candidates := []string{"cgroupfs"} - if ncdefaults.IsSystemdAvailable() { - candidates = append(candidates, "systemd") - } - if rootlessutil.IsRootless() { - candidates = append(candidates, "none") - } - return candidates, cobra.ShellCompDirectiveNoFileComp -} - func addApparmorCommand(rootCmd *cobra.Command) { - rootCmd.AddCommand(newApparmorCommand()) -} - -func addCpCommand(rootCmd *cobra.Command) { - rootCmd.AddCommand(newCpCommand()) + rootCmd.AddCommand(apparmor.NewApparmorCommand()) } diff --git a/cmd/nerdctl/main_linux_test.go b/cmd/nerdctl/main_linux_test.go deleted file mode 100644 index a61fa50ee2b..00000000000 --- a/cmd/nerdctl/main_linux_test.go +++ /dev/null @@ -1,36 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -// TestIssue108 tests https://github.com/containerd/nerdctl/issues/108 -// ("`nerdctl run --net=host -it` fails while `nerdctl run -it --net=host` works") -func TestIssue108(t *testing.T) { - base := testutil.NewBase(t) - // unbuffer(1) emulates tty, which is required by `nerdctl run -t`. - // unbuffer(1) can be installed with `apt-get install expect`. - unbuffer := []string{"unbuffer"} - base.CmdWithHelper(unbuffer, "run", "-it", "--rm", "--net=host", testutil.AlpineImage, - "echo", "this was always working").AssertOK() - base.CmdWithHelper(unbuffer, "run", "--rm", "--net=host", "-it", testutil.AlpineImage, - "echo", "this was not working due to issue #108").AssertOK() -} diff --git a/cmd/nerdctl/main_test.go b/cmd/nerdctl/main_test.go index 03a09360147..e2ca845e490 100644 --- a/cmd/nerdctl/main_test.go +++ b/cmd/nerdctl/main_test.go @@ -17,13 +17,14 @@ package main import ( - "os" - "path/filepath" + "errors" "testing" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" + "github.com/containerd/containerd/v2/defaults" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" ) func TestMain(m *testing.M) { @@ -32,59 +33,98 @@ func TestMain(m *testing.M) { // TestUnknownCommand tests https://github.com/containerd/nerdctl/issues/487 func TestUnknownCommand(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - base.Cmd("non-existent-command").AssertFail() - base.Cmd("non-existent-command", "info").AssertFail() - base.Cmd("system", "non-existent-command").AssertFail() - base.Cmd("system", "non-existent-command", "info").AssertFail() - base.Cmd("system").AssertOK() // show help without error - base.Cmd("system", "info").AssertOutContains("Kernel Version:") - base.Cmd("info").AssertOutContains("Kernel Version:") -} + testCase := nerdtest.Setup() -// TestNerdctlConfig validates the configuration precedence [CLI, Env, TOML, Default]. -func TestNerdctlConfig(t *testing.T) { - testutil.DockerIncompatible(t) - t.Parallel() - tomlPath := filepath.Join(t.TempDir(), "nerdctl.toml") - err := os.WriteFile(tomlPath, []byte(` -snapshotter = "dummy-snapshotter-via-toml" -`), 0400) - assert.NilError(t, err) - base := testutil.NewBase(t) - - // [Default] - base.Cmd("info", "-f", "{{.Driver}}").AssertOutExactly(containerd.DefaultSnapshotter + "\n") - - // [TOML, Default] - if len(base.Env) == 0 { - base.Env = os.Environ() + var unknownSubCommand = errors.New("unknown subcommand") + + testCase.SubTests = []*test.Case{ + { + Description: "non-existent-command", + Command: test.Command("non-existent-command"), + Expected: test.Expects(1, []error{unknownSubCommand}, nil), + }, + { + Description: "non-existent-command info", + Command: test.Command("non-existent-command", "info"), + Expected: test.Expects(1, []error{unknownSubCommand}, nil), + }, + { + Description: "system non-existent-command", + Command: test.Command("system", "non-existent-command"), + Expected: test.Expects(1, []error{unknownSubCommand}, nil), + }, + { + Description: "system non-existent-command info", + Command: test.Command("system", "non-existent-command", "info"), + Expected: test.Expects(1, []error{unknownSubCommand}, nil), + }, + { + Description: "system", + Command: test.Command("system"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "system info", + Command: test.Command("system", "info"), + Expected: test.Expects(0, nil, test.Contains("Kernel Version:")), + }, + { + Description: "info", + Command: test.Command("info"), + Expected: test.Expects(0, nil, test.Contains("Kernel Version:")), + }, } - base.Env = append(base.Env, "NERDCTL_TOML="+tomlPath) - base.Cmd("info", "-f", "{{.Driver}}").AssertOutExactly("dummy-snapshotter-via-toml\n") - // [CLI, TOML, Default] - base.Cmd("info", "-f", "{{.Driver}}", "--snapshotter=dummy-snapshotter-via-cli").AssertOutExactly("dummy-snapshotter-via-cli\n") + testCase.Run(t) +} - // [Env, TOML, Default] - base.Env = append(base.Env, "CONTAINERD_SNAPSHOTTER=dummy-snapshotter-via-env") - base.Cmd("info", "-f", "{{.Driver}}").AssertOutExactly("dummy-snapshotter-via-env\n") +// TestNerdctlConfig validates the configuration precedence [CLI, Env, TOML, Default] and broken config rejection +func TestNerdctlConfig(t *testing.T) { + testCase := nerdtest.Setup() - // [CLI, Env, TOML, Default] - base.Cmd("info", "-f", "{{.Driver}}", "--snapshotter=dummy-snapshotter-via-cli").AssertOutExactly("dummy-snapshotter-via-cli\n") -} + // Docker does not support nerdctl.toml obviously + testCase.Require = test.Not(nerdtest.Docker) + + testCase.SubTests = []*test.Case{ + { + Description: "Default", + Command: test.Command("info", "-f", "{{.Driver}}"), + Expected: test.Expects(0, nil, test.Equals(defaults.DefaultSnapshotter+"\n")), + }, + { + Description: "TOML > Default", + Command: test.Command("info", "-f", "{{.Driver}}"), + Expected: test.Expects(0, nil, test.Equals("dummy-snapshotter-via-toml\n")), + Config: test.WithConfig(nerdtest.NerdctlToml, `snapshotter = "dummy-snapshotter-via-toml"`), + }, + { + Description: "Cli > TOML > Default", + Command: test.Command("info", "-f", "{{.Driver}}", "--snapshotter=dummy-snapshotter-via-cli"), + Expected: test.Expects(0, nil, test.Equals("dummy-snapshotter-via-cli\n")), + Config: test.WithConfig(nerdtest.NerdctlToml, `snapshotter = "dummy-snapshotter-via-toml"`), + }, + { + Description: "Env > TOML > Default", + Command: test.Command("info", "-f", "{{.Driver}}"), + Env: map[string]string{"CONTAINERD_SNAPSHOTTER": "dummy-snapshotter-via-env"}, + Expected: test.Expects(0, nil, test.Equals("dummy-snapshotter-via-env\n")), + Config: test.WithConfig(nerdtest.NerdctlToml, `snapshotter = "dummy-snapshotter-via-toml"`), + }, + { + Description: "Cli > Env > TOML > Default", + Command: test.Command("info", "-f", "{{.Driver}}", "--snapshotter=dummy-snapshotter-via-cli"), + Env: map[string]string{"CONTAINERD_SNAPSHOTTER": "dummy-snapshotter-via-env"}, + Expected: test.Expects(0, nil, test.Equals("dummy-snapshotter-via-cli\n")), + Config: test.WithConfig(nerdtest.NerdctlToml, `snapshotter = "dummy-snapshotter-via-toml"`), + }, + { + Description: "Broken config", + Command: test.Command("info"), + Expected: test.Expects(1, []error{errors.New("failed to load nerdctl config")}, nil), + Config: test.WithConfig(nerdtest.NerdctlToml, `# containerd config, not nerdctl config +version = 2`), + }, + } -func TestNerdctlConfigBad(t *testing.T) { - testutil.DockerIncompatible(t) - t.Parallel() - tomlPath := filepath.Join(t.TempDir(), "config.toml") - err := os.WriteFile(tomlPath, []byte(` -# containerd config, not nerdctl config -version = 2 -`), 0400) - assert.NilError(t, err) - base := testutil.NewBase(t) - base.Env = append(base.Env, "NERDCTL_TOML="+tomlPath) - base.Cmd("info").AssertFail() + testCase.Run(t) } diff --git a/cmd/nerdctl/main_test_test.go b/cmd/nerdctl/main_test_test.go new file mode 100644 index 00000000000..695c52bac07 --- /dev/null +++ b/cmd/nerdctl/main_test_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 main + +import ( + "errors" + "log" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +// TestTest is testing the test tooling itself +func TestTest(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "failure", + Command: test.Command("undefinedcommand"), + Expected: test.Expects(1, nil, nil), + }, + { + Description: "success", + Command: test.Command("info"), + Expected: test.Expects(0, nil, nil), + }, + { + Description: "failure with single error testing", + Command: test.Command("undefinedcommand"), + Expected: test.Expects(1, []error{errors.New("unknown subcommand")}, nil), + }, + { + Description: "success with contains output testing", + Command: test.Command("info"), + Expected: test.Expects(0, nil, test.Contains("Kernel")), + }, + { + Description: "success with negative output testing", + Command: test.Command("info"), + Expected: test.Expects(0, nil, test.DoesNotContain("foobar")), + }, + // Note that docker annoyingly returns 125 in a few conditions like this + { + Description: "failure with multiple error testing", + Command: test.Command("-fail"), + Expected: test.Expects(-1, []error{errors.New("unknown"), errors.New("shorthand")}, nil), + }, + { + Description: "success with exact output testing", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("echo", "foobar") + }, + Expected: test.Expects(0, nil, test.Equals("foobar\n")), + }, + { + Description: "data propagation", + Data: test.WithData("status", "uninitialized"), + Setup: func(data test.Data, helpers test.Helpers) { + data.Set("status", data.Get("status")+"-setup") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Custom("printf", data.Get("status")) + data.Set("status", data.Get("status")+"-command") + return cmd + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if data.Get("status") == "uninitialized" { + return + } + if data.Get("status") != "uninitialized-setup-command" { + log.Fatalf("unexpected status label %q", data.Get("status")) + } + data.Set("status", data.Get("status")+"-cleanup") + }, + SubTests: []*test.Case{ + { + Description: "Subtest data propagation", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("printf", data.Get("status")) + }, + Expected: test.Expects(0, nil, test.Equals("uninitialized-setup-command")), + }, + }, + Expected: test.Expects(0, nil, test.Equals("uninitialized-setup")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/main_windows.go b/cmd/nerdctl/main_windows.go index e8c48782a20..391d34cfeed 100644 --- a/cmd/nerdctl/main_windows.go +++ b/cmd/nerdctl/main_windows.go @@ -24,22 +24,6 @@ func appNeedsRootlessParentMain(cmd *cobra.Command, args []string) bool { return false } -func shellCompleteNamespaceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} - -func shellCompleteSnapshotterNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} - -func shellCompleteCgroupManagerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp -} - func addApparmorCommand(rootCmd *cobra.Command) { // NOP } - -func addCpCommand(rootCmd *cobra.Command) { - // NOP -} diff --git a/cmd/nerdctl/namespace.go b/cmd/nerdctl/namespace/namespace.go similarity index 83% rename from cmd/nerdctl/namespace.go rename to cmd/nerdctl/namespace/namespace.go index 6ede2e28aa3..6885619d341 100644 --- a/cmd/nerdctl/namespace.go +++ b/cmd/nerdctl/namespace/namespace.go @@ -14,30 +14,32 @@ limitations under the License. */ -package main +package namespace import ( "fmt" - "os" "sort" "strings" "text/tabwriter" - "github.com/containerd/containerd/namespaces" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" - "github.com/sirupsen/logrus" "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 newNamespaceCommand() *cobra.Command { +func NewNamespaceCommand() *cobra.Command { namespaceCommand := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "namespace", Aliases: []string{"ns"}, Short: "Manage containerd namespaces", Long: "Unrelated to Linux namespaces and Kubernetes namespaces", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } @@ -63,7 +65,7 @@ func newNamespaceLsCommand() *cobra.Command { } func namespaceLsAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -103,27 +105,24 @@ func namespaceLsAction(cmd *cobra.Command, args []string) error { containers, err := client.Containers(ctx) if err != nil { - logrus.Warn(err) + log.L.Warn(err) } numContainers = len(containers) images, err := client.ImageService().List(ctx) if err != nil { - logrus.Warn(err) + log.L.Warn(err) } numImages = len(images) - volStore, err := volumestore.Path(dataStore, ns) + volStore, err := volumestore.New(dataStore, ns) if err != nil { - logrus.Warn(err) + log.L.Warn(err) } else { - volEnts, err := os.ReadDir(volStore) + numVolumes, err = volStore.Count() if err != nil { - if !os.IsNotExist(err) { - logrus.Warn(err) - } + log.L.Warn(err) } - numVolumes = len(volEnts) } labels, err := client.NamespaceService().Labels(ctx, ns) diff --git a/cmd/nerdctl/namespace_create.go b/cmd/nerdctl/namespace/namespace_create.go similarity index 86% rename from cmd/nerdctl/namespace_create.go rename to cmd/nerdctl/namespace/namespace_create.go index 2d8d6617137..4c6e51312fb 100644 --- a/cmd/nerdctl/namespace_create.go +++ b/cmd/nerdctl/namespace/namespace_create.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package namespace import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/namespace" "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 newNamespaceCreateCommand() *cobra.Command { @@ -38,7 +40,7 @@ func newNamespaceCreateCommand() *cobra.Command { } func processNamespaceCreateCommandOption(cmd *cobra.Command) (types.NamespaceCreateOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.NamespaceCreateOptions{}, err } diff --git a/cmd/nerdctl/namespace_inspect.go b/cmd/nerdctl/namespace/namespace_inspect.go similarity index 88% rename from cmd/nerdctl/namespace_inspect.go rename to cmd/nerdctl/namespace/namespace_inspect.go index b308c95b93f..cd3a5ff98e0 100644 --- a/cmd/nerdctl/namespace_inspect.go +++ b/cmd/nerdctl/namespace/namespace_inspect.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package namespace import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/namespace" "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 newNamespaceInspectCommand() *cobra.Command { @@ -40,7 +42,7 @@ func newNamespaceInspectCommand() *cobra.Command { } func processNamespaceInspectOptions(cmd *cobra.Command) (types.NamespaceInspectOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.NamespaceInspectOptions{}, err } diff --git a/cmd/nerdctl/namespace_remove.go b/cmd/nerdctl/namespace/namespace_remove.go similarity index 86% rename from cmd/nerdctl/namespace_remove.go rename to cmd/nerdctl/namespace/namespace_remove.go index 7f23031b581..b665736b1d8 100644 --- a/cmd/nerdctl/namespace_remove.go +++ b/cmd/nerdctl/namespace/namespace_remove.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package namespace import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/namespace" "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 newNamespaceRmCommand() *cobra.Command { @@ -38,7 +40,7 @@ func newNamespaceRmCommand() *cobra.Command { } func processNamespaceRemoveOptions(cmd *cobra.Command) (types.NamespaceRemoveOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.NamespaceRemoveOptions{}, err } diff --git a/cmd/nerdctl/namespace/namespace_test.go b/cmd/nerdctl/namespace/namespace_test.go new file mode 100644 index 00000000000..bad2c633371 --- /dev/null +++ b/cmd/nerdctl/namespace/namespace_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 namespace + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/namespace_update.go b/cmd/nerdctl/namespace/namespace_update.go similarity index 86% rename from cmd/nerdctl/namespace_update.go rename to cmd/nerdctl/namespace/namespace_update.go index fb58ae6edcc..b390dc26792 100644 --- a/cmd/nerdctl/namespace_update.go +++ b/cmd/nerdctl/namespace/namespace_update.go @@ -14,13 +14,15 @@ limitations under the License. */ -package main +package namespace import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/namespace" "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 newNamespacelabelUpdateCommand() *cobra.Command { @@ -37,7 +39,7 @@ func newNamespacelabelUpdateCommand() *cobra.Command { } func processNamespaceUpdateCommandOption(cmd *cobra.Command) (types.NamespaceUpdateOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.NamespaceUpdateOptions{}, err } diff --git a/cmd/nerdctl/network.go b/cmd/nerdctl/network/network.go similarity index 80% rename from cmd/nerdctl/network.go rename to cmd/nerdctl/network/network.go index cc9b38d5f51..b953135cd0e 100644 --- a/cmd/nerdctl/network.go +++ b/cmd/nerdctl/network/network.go @@ -14,18 +14,20 @@ limitations under the License. */ -package main +package network import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newNetworkCommand() *cobra.Command { +func NewNetworkCommand() *cobra.Command { networkCommand := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "network", Short: "Manage networks", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/nerdctl/network_create.go b/cmd/nerdctl/network/network_create.go similarity index 66% rename from cmd/nerdctl/network_create.go rename to cmd/nerdctl/network/network_create.go index c7452277bdf..1b996d27f4d 100644 --- a/cmd/nerdctl/network_create.go +++ b/cmd/nerdctl/network/network_create.go @@ -14,18 +14,19 @@ limitations under the License. */ -package main +package network import ( "fmt" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/network" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/strutil" - "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/network" + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func newNetworkCreateCommand() *cobra.Command { @@ -33,35 +34,33 @@ func newNetworkCreateCommand() *cobra.Command { Use: "create [flags] NETWORK", Short: "Create a network", Long: `NOTE: To isolate CNI bridge, CNI plugin "firewall" (>= v1.1.0) is needed.`, - Args: IsExactArgs(1), + Args: helpers.IsExactArgs(1), RunE: networkCreateAction, SilenceUsage: true, SilenceErrors: true, } networkCreateCommand.Flags().StringP("driver", "d", DefaultNetworkDriver, "Driver to manage the Network") - networkCreateCommand.RegisterFlagCompletionFunc("driver", shellCompleteNetworkDrivers) + networkCreateCommand.RegisterFlagCompletionFunc("driver", completion.NetworkDrivers) networkCreateCommand.Flags().StringArrayP("opt", "o", nil, "Set driver specific options") - networkCreateCommand.Flags().String("ipam-driver", "default", "IP Address Management Driver") - networkCreateCommand.RegisterFlagCompletionFunc("ipam-driver", shellCompleteIPAMDrivers) + networkCreateCommand.Flags().String("ipam-driver", "default", "IP Address helpers.Management Driver") + networkCreateCommand.RegisterFlagCompletionFunc("ipam-driver", completion.IPAMDrivers) networkCreateCommand.Flags().StringArray("ipam-opt", nil, "Set IPAM driver specific options") - networkCreateCommand.Flags().String("subnet", "", `Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"`) + networkCreateCommand.Flags().StringArray("subnet", nil, `Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"`) networkCreateCommand.Flags().String("gateway", "", `Gateway for the master subnet`) networkCreateCommand.Flags().String("ip-range", "", `Allocate container ip from a sub-range`) networkCreateCommand.Flags().StringArray("label", nil, "Set metadata for a network") + networkCreateCommand.Flags().Bool("ipv6", false, "Enable IPv6 networking") return networkCreateCommand } func networkCreateAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } name := args[0] - if err := identifiers.Validate(name); err != nil { - return fmt.Errorf("malformed name %s: %w", name, err) - } - if err != nil { - return err + if err := identifiers.ValidateDockerCompat(name); err != nil { + return fmt.Errorf("invalid network name: %w", err) } driver, err := cmd.Flags().GetString("driver") if err != nil { @@ -79,7 +78,7 @@ func networkCreateAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - subnetStr, err := cmd.Flags().GetString("subnet") + subnets, err := cmd.Flags().GetStringArray("subnet") if err != nil { return err } @@ -96,19 +95,22 @@ func networkCreateAction(cmd *cobra.Command, args []string) error { return err } labels = strutil.DedupeStrSlice(labels) + ipv6, err := cmd.Flags().GetBool("ipv6") + if err != nil { + return err + } return network.Create(types.NetworkCreateOptions{ - GOptions: globalOptions, - CreateOptions: netutil.CreateOptions{ - Name: name, - Driver: driver, - Options: strutil.ConvertKVStringsToMap(opts), - IPAMDriver: ipamDriver, - IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts), - Subnet: subnetStr, - Gateway: gatewayStr, - IPRange: ipRangeStr, - Labels: labels, - }, + GOptions: globalOptions, + Name: name, + Driver: driver, + Options: strutil.ConvertKVStringsToMap(opts), + IPAMDriver: ipamDriver, + IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts), + Subnets: subnets, + Gateway: gatewayStr, + IPRange: ipRangeStr, + Labels: labels, + IPv6: ipv6, }, cmd.OutOrStdout()) } diff --git a/cmd/nerdctl/network/network_create_linux_test.go b/cmd/nerdctl/network/network_create_linux_test.go new file mode 100644 index 00000000000..15012eabe88 --- /dev/null +++ b/cmd/nerdctl/network/network_create_linux_test.go @@ -0,0 +1,110 @@ +/* + 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 network + +import ( + "net" + "strings" + "testing" + + "gotest.tools/v3/assert" + + ipv6helper "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestNetworkCreate(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "vanilla", + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("network", "create", identifier) + netw := nerdtest.InspectNetwork(helpers, identifier) + assert.Equal(t, len(netw.IPAM.Config), 1) + data.Set("subnet", netw.IPAM.Config[0].Subnet) + + helpers.Ensure("network", "create", data.Identifier("1")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + helpers.Anyhow("network", "rm", data.Identifier("1")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + data.Set("container2", helpers.Capture("run", "--rm", "--net", data.Identifier("1"), testutil.CommonImage, "ip", "route")) + 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, + Errors: nil, + Output: func(stdout string, info string, t *testing.T) { + assert.Assert(t, strings.Contains(stdout, data.Get("subnet")), info) + assert.Assert(t, !strings.Contains(data.Get("container2"), data.Get("subnet")), info) + }, + } + }, + }, + { + Description: "with MTU", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier(), "--driver", "bridge", "--opt", "com.docker.network.driver.mtu=9216") + }, + 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, "ifconfig", "eth0") + }, + Expected: test.Expects(0, nil, test.Contains("MTU:9216")), + }, + { + Description: "with ipv6", + Require: nerdtest.OnlyIPv6, + Setup: func(data test.Data, helpers test.Helpers) { + subnetStr := "2001:db8:8::/64" + data.Set("subnetStr", subnetStr) + _, _, err := net.ParseCIDR(subnetStr) + assert.Assert(t, err == nil) + + helpers.Ensure("network", "create", data.Identifier(), "--ipv6", "--subnet", subnetStr) + }, + 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", "addr", "show", "dev", "eth0") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + _, subnet, _ := net.ParseCIDR(data.Get("subnetStr")) + ip := ipv6helper.FindIPv6(stdout) + assert.Assert(t, subnet.Contains(ip), info) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/network/network_create_unix.go b/cmd/nerdctl/network/network_create_unix.go new file mode 100644 index 00000000000..dbfb8a9781c --- /dev/null +++ b/cmd/nerdctl/network/network_create_unix.go @@ -0,0 +1,21 @@ +//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 network + +const DefaultNetworkDriver = "bridge" diff --git a/cmd/nerdctl/network/network_create_windows.go b/cmd/nerdctl/network/network_create_windows.go new file mode 100644 index 00000000000..1be4a08f4e4 --- /dev/null +++ b/cmd/nerdctl/network/network_create_windows.go @@ -0,0 +1,19 @@ +/* + 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 network + +const DefaultNetworkDriver = "nat" diff --git a/cmd/nerdctl/network_inspect.go b/cmd/nerdctl/network/network_inspect.go similarity index 88% rename from cmd/nerdctl/network_inspect.go rename to cmd/nerdctl/network/network_inspect.go index cea18685fcd..6a0c0a6bc53 100644 --- a/cmd/nerdctl/network_inspect.go +++ b/cmd/nerdctl/network/network_inspect.go @@ -14,12 +14,15 @@ limitations under the License. */ -package main +package network import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/network" "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/network" ) func newNetworkInspectCommand() *cobra.Command { @@ -44,7 +47,7 @@ func newNetworkInspectCommand() *cobra.Command { } func networkInspectAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -68,5 +71,5 @@ func networkInspectAction(cmd *cobra.Command, args []string) error { func networkInspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show network names, including "bridge" exclude := []string{"host", "none"} - return shellCompleteNetworkNames(cmd, exclude) + return completion.NetworkNames(cmd, exclude) } diff --git a/cmd/nerdctl/network/network_inspect_test.go b/cmd/nerdctl/network/network_inspect_test.go new file mode 100644 index 00000000000..d493db498fd --- /dev/null +++ b/cmd/nerdctl/network/network_inspect_test.go @@ -0,0 +1,281 @@ +/* + 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 network + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestNetworkInspect(t *testing.T) { + testCase := nerdtest.Setup() + + const ( + testSubnet = "10.24.24.0/24" + testGateway = "10.24.24.1" + testIPRange = "10.24.24.0/25" + ) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier("basenet")) + data.Set("basenet", data.Identifier("basenet")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier("basenet")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "non existent network", + Command: test.Command("network", "inspect", "nonexistent"), + // FIXME: where is this error even comin from? + Expected: test.Expects(1, []error{errors.New("no network found matching")}, nil), + }, + { + Description: "invalid name network", + Command: test.Command("network", "inspect", "∞"), + // FIXME: this is not even a valid identifier + Expected: test.Expects(1, []error{errors.New("no network found matching")}, nil), + }, + { + 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) { + 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.Equal(t, dc[0].Name, "none") + }), + }, + { + 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) { + 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.Equal(t, dc[0].Name, "host") + }), + }, + { + Description: "bridge", + Require: test.Not(test.Windows), + Command: test.Command("network", "inspect", "bridge"), + Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, "bridge") + }), + }, + { + Description: "nat", + Require: test.Windows, + Command: test.Command("network", "inspect", "nat"), + Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, "nat") + }), + }, + { + Description: "custom", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", "custom") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "remove", "custom") + }, + Command: test.Command("network", "inspect", "custom"), + Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, "custom") + }), + }, + { + Description: "match exact id", + // See notes below + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + id := strings.TrimSpace(helpers.Capture("network", "inspect", data.Get("basenet"), "--format", "{{ .Id }}")) + return helpers.Command("network", "inspect", id) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, data.Get("basenet")) + }, + } + }, + }, + { + Description: "match part of id", + // FIXME: for windows, network inspect testnetworkinspect-basenet-468cf999 --format {{ .Id }} MAY fail here + // This is bizarre, as it is working in the match exact id test - and there does not seem to be a particular reason for that + Require: test.Not(test.Windows), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + id := strings.TrimSpace(helpers.Capture("network", "inspect", data.Get("basenet"), "--format", "{{ .Id }}")) + return helpers.Command("network", "inspect", id[0:25]) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, data.Get("basenet")) + }, + } + }, + }, + { + Description: "using another net short id", + // FIXME: for windows, network inspect testnetworkinspect-basenet-468cf999 --format {{ .Id }} MAY fail here + // This is bizarre, as it is working in the match exact id test - and there does not seem to be a particular reason for that + Require: test.Not(test.Windows), + Setup: func(data test.Data, helpers test.Helpers) { + id := strings.TrimSpace(helpers.Capture("network", "inspect", data.Get("basenet"), "--format", "{{ .Id }}")) + helpers.Ensure("network", "create", id[0:12]) + data.Set("netname", id[0:12]) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "remove", data.Get("netname")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "inspect", data.Get("netname")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.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.Equal(t, dc[0].Name, data.Get("netname")) + }, + } + }, + }, + { + Description: "basic", + // FIXME: IPAMConfig is not implemented on Windows yet + Require: test.Not(test.Windows), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", "--label", "tag=testNetwork", "--subnet", testSubnet, + "--gateway", testGateway, "--ip-range", testIPRange, data.Identifier()) + }, + 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("network", "inspect", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.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) + 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) + }, + } + }, + }, + { + Description: "with namespace", + Require: test.Not(nerdtest.Docker), + Cleanup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Anyhow("network", "rm", identifier) + helpers.Anyhow("namespace", "remove", identifier) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "create", data.Identifier()) + }, + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + cmd := helpers.Custom("nerdctl", "--namespace", data.Identifier()) + + com := cmd.Clone() + com.WithArgs("network", "inspect", data.Identifier()) + com.Run(&test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("no network found")}, + }) + + com = cmd.Clone() + com.WithArgs("network", "remove", data.Identifier()) + com.Run(&test.Expected{ + ExitCode: 1, + Errors: []error{errors.New("no network found")}, + }) + + com = cmd.Clone() + com.WithArgs("network", "ls") + com.Run(&test.Expected{ + Output: test.DoesNotContain(data.Identifier()), + }) + + com = cmd.Clone() + com.WithArgs("network", "prune", "-f") + com.Run(&test.Expected{ + Output: test.DoesNotContain(data.Identifier()), + }) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/network_list.go b/cmd/nerdctl/network/network_list.go similarity index 79% rename from cmd/nerdctl/network_list.go rename to cmd/nerdctl/network/network_list.go index c45b1e6f7b1..22a10a239cd 100644 --- a/cmd/nerdctl/network_list.go +++ b/cmd/nerdctl/network/network_list.go @@ -14,12 +14,14 @@ limitations under the License. */ -package main +package network import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/network" "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/network" ) func newNetworkLsCommand() *cobra.Command { @@ -33,7 +35,7 @@ func newNetworkLsCommand() *cobra.Command { SilenceErrors: true, } cmd.Flags().BoolP("quiet", "q", false, "Only display network IDs") - // Alias "-f" is reserved for "--filter" + cmd.Flags().StringSliceP("filter", "f", []string{}, "Provide filter values (e.g. \"name=default\")") cmd.Flags().String("format", "", "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) { return []string{"json", "table", "wide"}, cobra.ShellCompDirectiveNoFileComp @@ -42,7 +44,7 @@ func newNetworkLsCommand() *cobra.Command { } func networkLsAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -54,10 +56,15 @@ func networkLsAction(cmd *cobra.Command, args []string) error { if err != nil { return err } + filters, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return err + } return network.List(cmd.Context(), types.NetworkListOptions{ GOptions: globalOptions, Quiet: quiet, Format: format, + Filters: filters, Stdout: cmd.OutOrStdout(), }) } diff --git a/cmd/nerdctl/network/network_list_linux_test.go b/cmd/nerdctl/network/network_list_linux_test.go new file mode 100644 index 00000000000..22620287a66 --- /dev/null +++ b/cmd/nerdctl/network/network_list_linux_test.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 network + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestNetworkLsFilter(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Set("identifier", data.Identifier()) + data.Set("label", "mylabel=label-1") + data.Set("net1", data.Identifier("1")) + data.Set("net2", data.Identifier("2")) + data.Set("netID1", helpers.Capture("network", "create", "--label="+data.Get("label"), data.Get("net1"))) + data.Set("netID2", helpers.Capture("network", "create", data.Get("net2"))) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier("1")) + helpers.Anyhow("network", "rm", data.Identifier("2")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "filter label", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "ls", "--quiet", "--filter", "label="+data.Get("label")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1, info) + netNames := map[string]struct{}{ + data.Get("netID1")[:12]: {}, + } + + for _, name := range lines { + _, ok := netNames[name] + assert.Assert(t, ok, info) + } + }, + } + }, + }, + { + Description: "filter name", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "ls", "--quiet", "--filter", "name="+data.Get("net2")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1, info) + netNames := map[string]struct{}{ + data.Get("netID2")[:12]: {}, + } + + for _, name := range lines { + _, ok := netNames[name] + assert.Assert(t, ok, info) + } + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/network_prune.go b/cmd/nerdctl/network/network_prune.go similarity index 83% rename from cmd/nerdctl/network_prune.go rename to cmd/nerdctl/network/network_prune.go index f67996a2733..f9114fe6bbe 100644 --- a/cmd/nerdctl/network_prune.go +++ b/cmd/nerdctl/network/network_prune.go @@ -14,19 +14,21 @@ limitations under the License. */ -package main +package network import ( "fmt" "strings" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/network" "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/network" ) -var networkDriversToKeep = []string{"host", "none", DefaultNetworkDriver} +var NetworkDriversToKeep = []string{"host", "none", DefaultNetworkDriver} func newNetworkPruneCommand() *cobra.Command { networkPruneCommand := &cobra.Command{ @@ -42,7 +44,7 @@ func newNetworkPruneCommand() *cobra.Command { } func networkPruneAction(cmd *cobra.Command, _ []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -65,7 +67,7 @@ func networkPruneAction(cmd *cobra.Command, _ []string) error { } options := types.NetworkPruneOptions{ GOptions: globalOptions, - NetworkDriversToKeep: networkDriversToKeep, + NetworkDriversToKeep: NetworkDriversToKeep, Stdout: cmd.OutOrStdout(), } diff --git a/cmd/nerdctl/network/network_prune_linux_test.go b/cmd/nerdctl/network/network_prune_linux_test.go new file mode 100644 index 00000000000..5c1fc0bfb17 --- /dev/null +++ b/cmd/nerdctl/network/network_prune_linux_test.go @@ -0,0 +1,74 @@ +/* + 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 network + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestNetworkPrune(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.Private + + testCase.SubTests = []*test.Case{ + { + Description: "Prune does not collect started container network", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("network", "create", identifier) + helpers.Ensure("run", "-d", "--net", identifier, "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: test.Command("network", "prune", "-f"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.DoesNotContain(data.Identifier()), + } + }, + }, + { + Description: "Prune does collect stopped container network", + NoParallel: true, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier()) + helpers.Ensure("run", "-d", "--net", data.Identifier(), "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("stop", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: test.Command("network", "prune", "-f"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Identifier()), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/network_remove.go b/cmd/nerdctl/network/network_remove.go similarity index 80% rename from cmd/nerdctl/network_remove.go rename to cmd/nerdctl/network/network_remove.go index 4d26b579461..907908979bc 100644 --- a/cmd/nerdctl/network_remove.go +++ b/cmd/nerdctl/network/network_remove.go @@ -14,15 +14,17 @@ limitations under the License. */ -package main +package network import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/network" - "github.com/containerd/nerdctl/pkg/netutil" - "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/network" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) func newNetworkRmCommand() *cobra.Command { @@ -41,7 +43,7 @@ func newNetworkRmCommand() *cobra.Command { } func networkRmAction(cmd *cobra.Command, args []string) error { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -64,5 +66,5 @@ func networkRmAction(cmd *cobra.Command, args []string) error { func networkRmShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show network names, including "bridge" exclude := []string{netutil.DefaultNetworkName, "host", "none"} - return shellCompleteNetworkNames(cmd, exclude) + return completion.NetworkNames(cmd, exclude) } diff --git a/cmd/nerdctl/network/network_remove_linux_test.go b/cmd/nerdctl/network/network_remove_linux_test.go new file mode 100644 index 00000000000..f1d6e2e5876 --- /dev/null +++ b/cmd/nerdctl/network/network_remove_linux_test.go @@ -0,0 +1,134 @@ +/* + 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 network + +import ( + "errors" + "testing" + + "github.com/vishvananda/netlink" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestNetworkRemove(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.Rootful + + testCase.SubTests = []*test.Case{ + { + Description: "Simple network remove", + Setup: func(data test.Data, helpers test.Helpers) { + identifier := data.Identifier() + helpers.Ensure("network", "create", identifier) + data.Set("netID", nerdtest.InspectNetwork(helpers, identifier).ID) + helpers.Ensure("run", "--rm", "--net", identifier, "--name", identifier, testutil.CommonImage) + // Verity the network is here + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.NilError(t, err, "failed to find network br-"+data.Get("netID")[:12], "%v") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.Error(t, err, "Link not found", info) + }, + } + }, + }, + { + Description: "Network remove when linked to container", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier()) + helpers.Ensure("run", "-d", "--net", data.Identifier(), "--name", data.Identifier(), testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "rm", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Expected: test.Expects(1, []error{errors.New("is in use")}, nil), + }, + { + Description: "Network remove by id", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier()) + data.Set("netID", nerdtest.InspectNetwork(helpers, data.Identifier()).ID) + helpers.Ensure("run", "--rm", "--net", data.Identifier(), "--name", data.Identifier(), testutil.CommonImage) + // Verity the network is here + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.NilError(t, err, "failed to find network br-"+data.Get("netID")[:12], "%v") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "rm", data.Get("netID")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.Error(t, err, "Link not found", info) + }, + } + }, + }, + { + Description: "Network remove by short id", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier()) + data.Set("netID", nerdtest.InspectNetwork(helpers, data.Identifier()).ID) + helpers.Ensure("run", "--rm", "--net", data.Identifier(), "--name", data.Identifier(), testutil.CommonImage) + // Verity the network is here + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.NilError(t, err, "failed to find network br-"+data.Get("netID")[:12], "%v") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("network", "rm", data.Get("netID")[:12]) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + _, err := netlink.LinkByName("br-" + data.Get("netID")[:12]) + assert.Error(t, err, "Link not found", info) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/network/network_test.go b/cmd/nerdctl/network/network_test.go new file mode 100644 index 00000000000..245f26e3b09 --- /dev/null +++ b/cmd/nerdctl/network/network_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 network + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/network_create_linux_test.go b/cmd/nerdctl/network_create_linux_test.go deleted file mode 100644 index d055c10bb28..00000000000 --- a/cmd/nerdctl/network_create_linux_test.go +++ /dev/null @@ -1,56 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestNetworkCreateWithMTU(t *testing.T) { - testNetwork := testutil.Identifier(t) - base := testutil.NewBase(t) - - args := []string{ - "network", "create", testNetwork, - "--driver", "bridge", "--opt", "com.docker.network.driver.mtu=9216", - } - base.Cmd(args...).AssertOK() - defer base.Cmd("network", "rm", testNetwork).AssertOK() - - base.Cmd("run", "--rm", "--net", testNetwork, testutil.AlpineImage, "ifconfig", "eth0").AssertOutContains("MTU:9216") -} - -func TestNetworkCreate(t *testing.T) { - base := testutil.NewBase(t) - testNetwork := testutil.Identifier(t) - - base.Cmd("network", "create", testNetwork).AssertOK() - defer base.Cmd("network", "rm", testNetwork).AssertOK() - - net := base.InspectNetwork(testNetwork) - assert.Equal(t, len(net.IPAM.Config), 1) - - base.Cmd("run", "--rm", "--net", testNetwork, testutil.CommonImage, "ip", "route").AssertOutContains(net.IPAM.Config[0].Subnet) - - base.Cmd("network", "create", testNetwork+"-1").AssertOK() - defer base.Cmd("network", "rm", testNetwork+"-1").AssertOK() - - base.Cmd("run", "--rm", "--net", testNetwork+"-1", testutil.CommonImage, "ip", "route").AssertNoOut(net.IPAM.Config[0].Subnet) -} diff --git a/cmd/nerdctl/network_create_unix.go b/cmd/nerdctl/network_create_unix.go deleted file mode 100644 index 298030ba5bc..00000000000 --- a/cmd/nerdctl/network_create_unix.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build freebsd || 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 main - -import "github.com/spf13/cobra" - -const ( - DefaultNetworkDriver = "bridge" -) - -func shellCompleteNetworkDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - candidates := []string{"bridge", "macvlan", "ipvlan"} - return candidates, cobra.ShellCompDirectiveNoFileComp -} - -func shellCompleteIPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"default", "host-local", "dhcp"}, cobra.ShellCompDirectiveNoFileComp -} diff --git a/cmd/nerdctl/network_inspect_test.go b/cmd/nerdctl/network_inspect_test.go deleted file mode 100644 index 76b93ec7097..00000000000 --- a/cmd/nerdctl/network_inspect_test.go +++ /dev/null @@ -1,68 +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 main - -import ( - "runtime" - "testing" - - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestNetworkInspect(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("IPAMConfig not implemented on Windows yet") - } - - testNetwork := testutil.Identifier(t) - const ( - testSubnet = "10.24.24.0/24" - testGateway = "10.24.24.1" - testIPRange = "10.24.24.1/25" - ) - - base := testutil.NewBase(t) - defer base.Cmd("network", "rm", testNetwork).Run() - - args := []string{ - "network", "create", "--label", "tag=testNetwork", "--subnet", testSubnet, - "--gateway", testGateway, "--ip-range", testIPRange, - testNetwork, - } - base.Cmd(args...).AssertOK() - got := base.InspectNetwork(testNetwork) - - assert.DeepEqual(base.T, testNetwork, got.Name) - - expectedLabels := map[string]string{ - "tag": "testNetwork", - } - assert.DeepEqual(base.T, expectedLabels, got.Labels) - - expectedIPAM := dockercompat.IPAM{ - Config: []dockercompat.IPAMConfig{ - { - Subnet: testSubnet, - Gateway: testGateway, - IPRange: testIPRange, - }, - }, - } - assert.DeepEqual(base.T, expectedIPAM, got.IPAM) -} diff --git a/cmd/nerdctl/network_prune_linux_test.go b/cmd/nerdctl/network_prune_linux_test.go deleted file mode 100644 index d872d540b26..00000000000 --- a/cmd/nerdctl/network_prune_linux_test.go +++ /dev/null @@ -1,38 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestNetworkPrune(t *testing.T) { - base := testutil.NewBase(t) - testNetwork := testutil.Identifier(t) - base.Cmd("network", "create", testNetwork).AssertOK() - defer base.Cmd("network", "prune", "-f").Run() - - tID := testutil.Identifier(t) - base.Cmd("run", "-d", "--net", testNetwork, "--name", tID, testutil.NginxAlpineImage).AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - - base.Cmd("network", "prune", "-f").AssertNoOut(testNetwork) - base.Cmd("stop", tID).AssertOK() - base.Cmd("network", "prune", "-f").AssertOutContains(testNetwork) -} diff --git a/cmd/nerdctl/network_remove_linux_test.go b/cmd/nerdctl/network_remove_linux_test.go deleted file mode 100644 index 9003f2a4bcf..00000000000 --- a/cmd/nerdctl/network_remove_linux_test.go +++ /dev/null @@ -1,138 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/vishvananda/netlink" - "gotest.tools/v3/assert" -) - -func TestNetworkRemoveInOtherNamespace(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("test skipped for remove rootless network") - } - if testutil.GetTarget() == testutil.Docker { - t.Skip("test skipped for docker") - } - // --namespace=nerdctl-test - base := testutil.NewBase(t) - // --namespace=nerdctl-other - baseOther := testutil.NewBaseWithNamespace(t, "nerdctl-other") - networkName := testutil.Identifier(t) - - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).AssertOK() - - tID := testutil.Identifier(t) - base.Cmd("run", "-d", "--net", networkName, "--name", tID, testutil.AlpineImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - - // delete network in namespace nerdctl-other - baseOther.Cmd("network", "rm", networkName).AssertFail() -} - -func TestNetworkRemove(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("test skipped for remove rootless network") - } - base := testutil.NewBase(t) - networkName := testutil.Identifier(t) - - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).Run() - - networkID := base.InspectNetwork(networkName).ID - - tID := testutil.Identifier(t) - base.Cmd("run", "--rm", "--net", networkName, "--name", tID, testutil.CommonImage).AssertOK() - - _, err := netlink.LinkByName("br-" + networkID[:12]) - assert.NilError(t, err) - - base.Cmd("network", "rm", networkName).AssertOK() - - _, err = netlink.LinkByName("br-" + networkID[:12]) - assert.Error(t, err, "Link not found") -} - -func TestNetworkRemoveWhenLinkWithContainer(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("test skipped for remove rootless network") - } - base := testutil.NewBase(t) - networkName := testutil.Identifier(t) - - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).AssertOK() - - tID := testutil.Identifier(t) - base.Cmd("run", "-d", "--net", networkName, "--name", tID, testutil.AlpineImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - base.Cmd("network", "rm", networkName).AssertFail() -} - -func TestNetworkRemoveById(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("test skipped for remove rootless network") - } - base := testutil.NewBase(t) - networkName := testutil.Identifier(t) - - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).Run() - - networkID := base.InspectNetwork(networkName).ID - - tID := testutil.Identifier(t) - base.Cmd("run", "--rm", "--net", networkName, "--name", tID, testutil.CommonImage).AssertOK() - - _, err := netlink.LinkByName("br-" + networkID[:12]) - assert.NilError(t, err) - - base.Cmd("network", "rm", networkID).AssertOK() - - _, err = netlink.LinkByName("br-" + networkID[:12]) - assert.Error(t, err, "Link not found") -} - -func TestNetworkRemoveByShortId(t *testing.T) { - if rootlessutil.IsRootless() { - t.Skip("test skipped for remove rootless network") - } - base := testutil.NewBase(t) - networkName := testutil.Identifier(t) - - base.Cmd("network", "create", networkName).AssertOK() - defer base.Cmd("network", "rm", networkName).Run() - - networkID := base.InspectNetwork(networkName).ID - - tID := testutil.Identifier(t) - base.Cmd("run", "--rm", "--net", networkName, "--name", tID, testutil.CommonImage).AssertOK() - - _, err := netlink.LinkByName("br-" + networkID[:12]) - assert.NilError(t, err) - - base.Cmd("network", "rm", networkID[:12]).AssertOK() - - _, err = netlink.LinkByName("br-" + networkID[:12]) - assert.Error(t, err, "Link not found") -} diff --git a/cmd/nerdctl/system.go b/cmd/nerdctl/system/system.go similarity index 73% rename from cmd/nerdctl/system.go rename to cmd/nerdctl/system/system.go index bb9635be295..f4a41aba5ca 100644 --- a/cmd/nerdctl/system.go +++ b/cmd/nerdctl/system/system.go @@ -14,23 +14,27 @@ limitations under the License. */ -package main +package system -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" -func newSystemCommand() *cobra.Command { + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" +) + +func NewSystemCommand() *cobra.Command { var systemCommand = &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "system", Short: "Manage containerd", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } // versionCommand is not here systemCommand.AddCommand( - newEventsCommand(), - newInfoCommand(), + NewEventsCommand(), + NewInfoCommand(), newSystemPruneCommand(), ) return systemCommand diff --git a/cmd/nerdctl/system_events.go b/cmd/nerdctl/system/system_events.go similarity index 78% rename from cmd/nerdctl/system_events.go rename to cmd/nerdctl/system/system_events.go index 9455cd1ca59..69832df35ce 100644 --- a/cmd/nerdctl/system_events.go +++ b/cmd/nerdctl/system/system_events.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package system import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/system" "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/system" ) -func newEventsCommand() *cobra.Command { +func NewEventsCommand() *cobra.Command { shortHelp := `Get real time events from the server` longHelp := shortHelp + "\nNOTE: The output format is not compatible with Docker." var eventsCommand = &cobra.Command{ @@ -39,11 +41,12 @@ func newEventsCommand() *cobra.Command { eventsCommand.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"json"}, cobra.ShellCompDirectiveNoFileComp }) + eventsCommand.Flags().StringSliceP("filter", "f", []string{}, "Filter matches containers based on given conditions") return eventsCommand } func processSystemEventsOptions(cmd *cobra.Command) (types.SystemEventsOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.SystemEventsOptions{}, err } @@ -51,10 +54,15 @@ func processSystemEventsOptions(cmd *cobra.Command) (types.SystemEventsOptions, if err != nil { return types.SystemEventsOptions{}, err } + filters, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.SystemEventsOptions{}, err + } return types.SystemEventsOptions{ Stdout: cmd.OutOrStdout(), GOptions: globalOptions, Format: format, + Filters: filters, }, nil } diff --git a/cmd/nerdctl/system/system_events_linux_test.go b/cmd/nerdctl/system/system_events_linux_test.go new file mode 100644 index 00000000000..3dd5d30c40d --- /dev/null +++ b/cmd/nerdctl/system/system_events_linux_test.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 system + +import ( + "testing" + "time" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func testEventFilterExecutor(data test.Data, helpers test.Helpers) test.TestableCommand { + cmd := helpers.Command("events", "--filter", data.Get("filter"), "--format", "json") + cmd.Background(1 * time.Second) + helpers.Ensure("run", "--rm", testutil.CommonImage) + return cmd +} + +func TestEventFilters(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "CapitalizedFilter", + Require: test.Not(nerdtest.Docker), + Command: testEventFilterExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("output")), + } + }, + Data: test.WithData("filter", "event=START"). + Set("output", "\"Status\":\"start\""), + }, + { + Description: "StartEventFilter", + Command: testEventFilterExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("output")), + } + }, + Data: test.WithData("filter", "event=start"). + Set("output", "tatus\":\"start\""), + }, + { + Description: "UnsupportedEventFilter", + Require: test.Not(nerdtest.Docker), + Command: testEventFilterExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("output")), + } + }, + Data: test.WithData("filter", "event=unknown"). + Set("output", "\"Status\":\"unknown\""), + }, + { + Description: "StatusFilter", + Command: testEventFilterExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("output")), + } + }, + Data: test.WithData("filter", "status=start"). + Set("output", "tatus\":\"start\""), + }, + { + Description: "UnsupportedStatusFilter", + Require: test.Not(nerdtest.Docker), + Command: testEventFilterExecutor, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Contains(data.Get("output")), + } + }, + Data: test.WithData("filter", "status=unknown"). + Set("output", "\"Status\":\"unknown\""), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/system_info.go b/cmd/nerdctl/system/system_info.go similarity index 88% rename from cmd/nerdctl/system_info.go rename to cmd/nerdctl/system/system_info.go index 1a8c94304a9..10eeed9cd21 100644 --- a/cmd/nerdctl/system_info.go +++ b/cmd/nerdctl/system/system_info.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package system import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/system" "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/system" ) -func newInfoCommand() *cobra.Command { +func NewInfoCommand() *cobra.Command { var infoCommand = &cobra.Command{ Use: "info", Args: cobra.NoArgs, @@ -44,7 +46,7 @@ func newInfoCommand() *cobra.Command { } func processInfoOptions(cmd *cobra.Command) (types.SystemInfoOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.SystemInfoOptions{}, err } diff --git a/cmd/nerdctl/system/system_info_test.go b/cmd/nerdctl/system/system_info_test.go new file mode 100644 index 00000000000..dc4af4b0381 --- /dev/null +++ b/cmd/nerdctl/system/system_info_test.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 system + +import ( + "encoding/json" + "fmt" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func testInfoComparator(stdout string, info string, t *testing.T) { + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + assert.NilError(t, err, "failed to unmarshal stdout"+info) + unameM := infoutil.UnameM() + assert.Assert(t, dinf.Architecture == unameM, fmt.Sprintf("expected info.Architecture to be %q, got %q", unameM, dinf.Architecture)+info) +} + +func TestInfo(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "info", + Command: test.Command("info", "--format", "{{json .}}"), + Expected: test.Expects(0, nil, testInfoComparator), + }, + { + Description: "info convenience form", + Command: test.Command("info", "--format", "json"), + Expected: test.Expects(0, nil, testInfoComparator), + }, + { + Description: "info with namespace", + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("nerdctl", "info") + }, + Expected: test.Expects(0, nil, test.Contains("Namespace: default")), + }, + { + Description: "info with namespace env var", + Env: map[string]string{ + "CONTAINERD_NAMESPACE": "test", + }, + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Custom("nerdctl", "info") + }, + Expected: test.Expects(0, nil, test.Contains("Namespace: test")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/system_prune.go b/cmd/nerdctl/system/system_prune.go similarity index 83% rename from cmd/nerdctl/system_prune.go rename to cmd/nerdctl/system/system_prune.go index eea6031b609..29a43cb187e 100644 --- a/cmd/nerdctl/system_prune.go +++ b/cmd/nerdctl/system/system_prune.go @@ -14,17 +14,22 @@ limitations under the License. */ -package main +package system import ( "fmt" "strings" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/system" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/builder" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/network" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/system" ) func newSystemPruneCommand() *cobra.Command { @@ -43,7 +48,7 @@ func newSystemPruneCommand() *cobra.Command { } func processSystemPruneOptions(cmd *cobra.Command) (types.SystemPruneOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.SystemPruneOptions{}, err } @@ -58,9 +63,9 @@ func processSystemPruneOptions(cmd *cobra.Command) (types.SystemPruneOptions, er return types.SystemPruneOptions{}, err } - buildkitHost, err := getBuildkitHost(cmd, globalOptions.Namespace) + buildkitHost, err := builder.GetBuildkitHost(cmd, globalOptions.Namespace) if err != nil { - logrus.WithError(err).Warn("BuildKit is not running. Build caches will not be pruned.") + log.L.WithError(err).Warn("BuildKit is not running. Build caches will not be pruned.") buildkitHost = "" } @@ -71,7 +76,7 @@ func processSystemPruneOptions(cmd *cobra.Command) (types.SystemPruneOptions, er All: all, Volumes: vFlag, BuildKitHost: buildkitHost, - NetworkDriversToKeep: networkDriversToKeep, + NetworkDriversToKeep: network.NetworkDriversToKeep, }, nil } diff --git a/cmd/nerdctl/system/system_prune_linux_test.go b/cmd/nerdctl/system/system_prune_linux_test.go new file mode 100644 index 00000000000..1d47b26690c --- /dev/null +++ b/cmd/nerdctl/system/system_prune_linux_test.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 system + +import ( + "fmt" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestSystemPrune(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.NoParallel = true + + testCase.SubTests = []*test.Case{ + { + Description: "volume prune all success", + // Private because of prune evidently + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier()) + helpers.Ensure("volume", "create", data.Identifier()) + anonIdentifier := helpers.Capture("volume", "create") + helpers.Ensure("run", "-v", fmt.Sprintf("%s:/volume", data.Identifier()), + "--net", data.Identifier(), "--name", data.Identifier(), testutil.CommonImage) + + data.Set("anonIdentifier", anonIdentifier) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + helpers.Anyhow("volume", "rm", data.Identifier()) + helpers.Anyhow("volume", "rm", data.Get("anonIdentifier")) + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("system", "prune", "-f", "--volumes", "--all"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, info string, t *testing.T) { + volumes := helpers.Capture("volume", "ls") + networks := helpers.Capture("network", "ls") + images := helpers.Capture("images") + containers := helpers.Capture("ps", "-a") + assert.Assert(t, strings.Contains(volumes, data.Identifier()), volumes) + assert.Assert(t, !strings.Contains(volumes, data.Get("anonIdentifier")), volumes) + assert.Assert(t, !strings.Contains(containers, data.Identifier()), containers) + assert.Assert(t, !strings.Contains(networks, data.Identifier()), networks) + assert.Assert(t, !strings.Contains(images, testutil.CommonImage), images) + }, + } + }, + }, + { + Description: "buildkit", + // FIXME: using a dedicated namespace does not work with rootful (because of buildkitd) + NoParallel: true, + // buildkitd is not available with docker + Require: test.Require(nerdtest.Build, test.Not(nerdtest.Docker)), + // FIXME: this test will happily say "green" even if the command actually fails to do its duty + // if there is nothing in the build cache. + // Ensure with setup here that we DO build something first + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("system", "prune", "-f", "--volumes", "--all") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return nerdtest.BuildCtlCommand(helpers, "du") + }, + Expected: test.Expects(0, nil, test.Contains("Total:\t\t0B")), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/system/system_test.go b/cmd/nerdctl/system/system_test.go new file mode 100644 index 00000000000..2c066490dd6 --- /dev/null +++ b/cmd/nerdctl/system/system_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 system + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/system_info_test.go b/cmd/nerdctl/system_info_test.go deleted file mode 100644 index 7606b0ed601..00000000000 --- a/cmd/nerdctl/system_info_test.go +++ /dev/null @@ -1,62 +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 main - -import ( - "encoding/json" - "fmt" - "os" - "testing" - - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/testutil" -) - -func testInfoJSON(stdout string) error { - var info dockercompat.Info - if err := json.Unmarshal([]byte(stdout), &info); err != nil { - return err - } - unameM := infoutil.UnameM() - if info.Architecture != unameM { - return fmt.Errorf("expected info.Architecture to be %q, got %q", unameM, info.Architecture) - } - return nil -} - -func TestInfo(t *testing.T) { - base := testutil.NewBase(t) - base.Cmd("info", "--format", "{{json .}}").AssertOutWithFunc(testInfoJSON) -} - -func TestInfoConvenienceForm(t *testing.T) { - testutil.DockerIncompatible(t) // until https://github.com/docker/cli/pull/3355 gets merged - base := testutil.NewBase(t) - base.Cmd("info", "--format", "json").AssertOutWithFunc(testInfoJSON) -} - -func TestInfoWithNamespace(t *testing.T) { - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) - base.Args = nil // unset "--namespace=nerdctl-test" - - base.Cmd("info").AssertOutContains("Namespace: default") - - base.Env = append(os.Environ(), "CONTAINERD_NAMESPACE=test") - base.Cmd("info").AssertOutContains("Namespace: test") -} diff --git a/cmd/nerdctl/system_prune_linux_test.go b/cmd/nerdctl/system_prune_linux_test.go deleted file mode 100644 index 361698d0954..00000000000 --- a/cmd/nerdctl/system_prune_linux_test.go +++ /dev/null @@ -1,93 +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 main - -import ( - "bytes" - "fmt" - "io" - "os" - "os/exec" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/sirupsen/logrus" -) - -func TestSystemPrune(t *testing.T) { - testutil.RequiresBuild(t) - base := testutil.NewBase(t) - base.Cmd("container", "prune", "-f").AssertOK() - base.Cmd("network", "prune", "-f").AssertOK() - base.Cmd("volume", "prune", "-f").AssertOK() - base.Cmd("image", "prune", "-f", "--all").AssertOK() - - nID := testutil.Identifier(t) - base.Cmd("network", "create", nID).AssertOK() - defer base.Cmd("network", "rm", nID).Run() - - vID := testutil.Identifier(t) - base.Cmd("volume", "create", vID).AssertOK() - defer base.Cmd("volume", "rm", vID).Run() - - tID := testutil.Identifier(t) - base.Cmd("run", "-v", fmt.Sprintf("%s:/volume", vID), "--net", nID, - "--name", tID, testutil.CommonImage).AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - - base.Cmd("ps", "-a").AssertOutContains(tID) - base.Cmd("images").AssertOutContains(testutil.ImageRepo(testutil.CommonImage)) - - base.Cmd("system", "prune", "-f", "--volumes", "--all").AssertOK() - base.Cmd("volume", "ls").AssertNoOut(vID) - base.Cmd("ps", "-a").AssertNoOut(tID) - base.Cmd("network", "ls").AssertNoOut(nID) - base.Cmd("images").AssertNoOut(testutil.ImageRepo(testutil.CommonImage)) - - if testutil.GetTarget() != testutil.Nerdctl { - t.Skip("test skipped for buildkitd is not available with docker-compatible tests") - } - - buildctlBinary, err := buildkitutil.BuildctlBinary() - if err != nil { - t.Fatal(err) - } - host, err := buildkitutil.GetBuildkitHost(testutil.Namespace) - if err != nil { - t.Fatal(err) - } - - buildctlArgs := buildkitutil.BuildctlBaseArgs(host) - buildctlArgs = append(buildctlArgs, "du") - logrus.Debugf("running %s %v", buildctlBinary, buildctlArgs) - buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...) - buildctlCmd.Env = os.Environ() - stdout := bytes.NewBuffer(nil) - buildctlCmd.Stdout = stdout - if err := buildctlCmd.Run(); err != nil { - t.Fatal(err) - } - readAll, err := io.ReadAll(stdout) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(readAll), "Total:\t\t0B") { - t.Errorf("buildkit cache is not pruned: %s", string(readAll)) - } -} diff --git a/cmd/nerdctl/version.go b/cmd/nerdctl/version.go index d74444a2e4b..627c8b1940a 100644 --- a/cmd/nerdctl/version.go +++ b/cmd/nerdctl/version.go @@ -23,12 +23,16 @@ import ( "os" "text/template" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" "github.com/spf13/cobra" + + "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/formatter" + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func newVersionCommand() *cobra.Command { @@ -50,7 +54,7 @@ func newVersionCommand() *cobra.Command { func versionAction(cmd *cobra.Command, args []string) error { var w io.Writer = os.Stdout var tmpl *template.Template - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return err } @@ -66,17 +70,27 @@ func versionAction(cmd *cobra.Command, args []string) error { } } - v, vErr := versionInfo(cmd, globalOptions) + address := globalOptions.Address + // rootless `nerdctl version` runs in the host namespaces, so the address is different + if rootlessutil.IsRootless() { + address, err = rootlessutil.RootlessContainredSockAddress() + if err != nil { + log.L.WithError(err).Warning("failed to inspect the rootless containerd socket address") + address = "" + } + } + + v, vErr := versionInfo(cmd, globalOptions.Namespace, address) if tmpl != nil { var b bytes.Buffer if err := tmpl.Execute(&b, v); err != nil { return err } - if _, err := fmt.Fprintf(w, b.String()+"\n"); err != nil { + if _, err := fmt.Fprintln(w, b.String()); err != nil { return err } } else { - fmt.Fprintf(w, "Client:\n") + fmt.Fprintln(w, "Client:") fmt.Fprintf(w, " Version:\t%s\n", v.Client.Version) fmt.Fprintf(w, " OS/Arch:\t%s/%s\n", v.Client.Os, v.Client.Arch) fmt.Fprintf(w, " Git commit:\t%s\n", v.Client.GitCommit) @@ -88,8 +102,8 @@ func versionAction(cmd *cobra.Command, args []string) error { } } if v.Server != nil { - fmt.Fprintf(w, "\n") - fmt.Fprintf(w, "Server:\n") + fmt.Fprintln(w) + fmt.Fprintln(w, "Server:") for _, compo := range v.Server.Components { fmt.Fprintf(w, " %s:\n", compo.Name) fmt.Fprintf(w, " Version:\t%s\n", compo.Version) @@ -102,13 +116,16 @@ func versionAction(cmd *cobra.Command, args []string) error { return vErr } -// versionInfo may return partial VersionInfo on error -func versionInfo(cmd *cobra.Command, globalOptions types.GlobalCommandOptions) (dockercompat.VersionInfo, error) { - +// versionInfo may return partial VersionInfo on error. +// Address can be empty to skip inspecting the server. +func versionInfo(cmd *cobra.Command, ns, address string) (dockercompat.VersionInfo, error) { v := dockercompat.VersionInfo{ Client: infoutil.ClientVersion(), } - client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) + if address == "" { + return v, nil + } + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), ns, address) if err != nil { return v, err } diff --git a/cmd/nerdctl/volume.go b/cmd/nerdctl/volume/volume.go similarity index 80% rename from cmd/nerdctl/volume.go rename to cmd/nerdctl/volume/volume.go index b34d1cd471d..352993096b9 100644 --- a/cmd/nerdctl/volume.go +++ b/cmd/nerdctl/volume/volume.go @@ -14,18 +14,20 @@ limitations under the License. */ -package main +package volume import ( "github.com/spf13/cobra" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" ) -func newVolumeCommand() *cobra.Command { +func NewVolumeCommand() *cobra.Command { volumeCommand := &cobra.Command{ - Annotations: map[string]string{Category: Management}, + Annotations: map[string]string{helpers.Category: helpers.Management}, Use: "volume", Short: "Manage volumes", - RunE: unknownSubcommandAction, + RunE: helpers.UnknownSubcommandAction, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/nerdctl/volume_create.go b/cmd/nerdctl/volume/volume_create.go similarity index 69% rename from cmd/nerdctl/volume_create.go rename to cmd/nerdctl/volume/volume_create.go index 5122e60d465..6927c4a556e 100644 --- a/cmd/nerdctl/volume_create.go +++ b/cmd/nerdctl/volume/volume_create.go @@ -14,20 +14,25 @@ limitations under the License. */ -package main +package volume import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/volume" + "fmt" "github.com/spf13/cobra" + + "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/cmd/volume" ) func newVolumeCreateCommand() *cobra.Command { volumeCreateCommand := &cobra.Command{ - Use: "create [flags] VOLUME", + Use: "create [flags] [VOLUME]", Short: "Create a volume", - Args: IsExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: volumeCreateAction, SilenceUsage: true, SilenceErrors: true, @@ -37,7 +42,7 @@ func newVolumeCreateCommand() *cobra.Command { } func processVolumeCreateOptions(cmd *cobra.Command) (types.VolumeCreateOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.VolumeCreateOptions{}, err } @@ -45,6 +50,12 @@ func processVolumeCreateOptions(cmd *cobra.Command) (types.VolumeCreateOptions, if err != nil { return types.VolumeCreateOptions{}, err } + for _, label := range labels { + if label == "" { + return types.VolumeCreateOptions{}, fmt.Errorf("labels cannot be empty (%w)", errdefs.ErrInvalidArgument) + } + } + return types.VolumeCreateOptions{ GOptions: globalOptions, Labels: labels, @@ -55,7 +66,13 @@ func processVolumeCreateOptions(cmd *cobra.Command) (types.VolumeCreateOptions, func volumeCreateAction(cmd *cobra.Command, args []string) error { options, err := processVolumeCreateOptions(cmd) if err != nil { - return nil + return err + } + volumeName := "" + if len(args) > 0 { + volumeName = args[0] } - return volume.Create(args[0], options) + _, err = volume.Create(volumeName, options) + + return err } diff --git a/cmd/nerdctl/volume/volume_create_test.go b/cmd/nerdctl/volume/volume_create_test.go new file mode 100644 index 00000000000..1cde5336199 --- /dev/null +++ b/cmd/nerdctl/volume/volume_create_test.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 volume + +import ( + "errors" + "regexp" + "testing" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestVolumeCreate(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "arg missing should create anonymous volume", + Command: test.Command("volume", "create"), + Expected: test.Expects(0, nil, test.Match(regexp.MustCompile("^[a-f0-9]{64}\n$"))), + }, + { + Description: "invalid identifier should fail", + Command: test.Command("volume", "create", "∞"), + Expected: test.Expects(1, []error{errdefs.ErrInvalidArgument}, nil), + }, + { + Description: "too many args should fail", + Command: test.Command("volume", "create", "too", "many"), + Expected: test.Expects(1, []error{errors.New("at most 1 arg")}, nil), + }, + { + Description: "success", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "create", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + { + Description: "success with labels", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "create", "--label", "foo1=baz1", "--label", "foo2=baz2", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + { + Description: "invalid labels should fail", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // See https://github.com/containerd/nerdctl/issues/3126 + return helpers.Command("volume", "create", "--label", "a", "--label", "", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + // NOTE: docker returns 125 on this + Expected: test.Expects(-1, []error{errdefs.ErrInvalidArgument}, nil), + }, + { + Description: "creating already existing volume should succeed", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "create", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume_inspect.go b/cmd/nerdctl/volume/volume_inspect.go similarity index 86% rename from cmd/nerdctl/volume_inspect.go rename to cmd/nerdctl/volume/volume_inspect.go index 64ef29a35d2..422df57ab3e 100644 --- a/cmd/nerdctl/volume_inspect.go +++ b/cmd/nerdctl/volume/volume_inspect.go @@ -14,12 +14,15 @@ limitations under the License. */ -package main +package volume import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/volume" "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/volume" ) func newVolumeInspectCommand() *cobra.Command { @@ -41,7 +44,7 @@ func newVolumeInspectCommand() *cobra.Command { } func processVolumeInspectOptions(cmd *cobra.Command) (types.VolumeInspectOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.VolumeInspectOptions{}, err } @@ -66,10 +69,10 @@ func volumeInspectAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - return volume.Inspect(args, options) + return volume.Inspect(cmd.Context(), args, options) } func volumeInspectShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show volume names - return shellCompleteVolumeNames(cmd) + return completion.VolumeNames(cmd) } diff --git a/cmd/nerdctl/volume/volume_inspect_test.go b/cmd/nerdctl/volume/volume_inspect_test.go new file mode 100644 index 00000000000..a7ec478b55a --- /dev/null +++ b/cmd/nerdctl/volume/volume_inspect_test.go @@ -0,0 +1,211 @@ +/* + 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 volume + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func createFileWithSize(mountPoint string, size int64) error { + token := make([]byte, size) + _, _ = rand.Read(token) + err := os.WriteFile(filepath.Join(mountPoint, "test-file"), token, 0644) + return err +} + +func TestVolumeInspect(t *testing.T) { + var size int64 = 1028 + + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.BrokenTest("This test assumes that the host-side of a volume can be written into, "+ + "which is not always true. To be replaced by cp into the container.", + &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + isDocker, _ := nerdtest.Docker.Check(data, helpers) + return !isDocker || os.Geteuid() == 0, "docker cli needs to be run as root" + }, + }) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier("first")) + helpers.Ensure("volume", "create", "--label", "foo=fooval", "--label", "bar=barval", data.Identifier("second")) + // Obviously note here that if inspect code gets totally hosed, this entire suite will + // probably fail right here on the Setup instead of actually testing something + vol := nerdtest.InspectVolume(helpers, data.Identifier("first")) + err := createFileWithSize(vol.Mountpoint, size) + assert.NilError(t, err, "File creation failed") + data.Set("vol1", data.Identifier("first")) + data.Set("vol2", data.Identifier("second")) + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier("first")) + helpers.Anyhow("volume", "rm", "-f", data.Identifier("second")) + } + + testCase.SubTests = []*test.Case{ + { + Description: "arg missing should fail", + Command: test.Command("volume", "inspect"), + Expected: test.Expects(1, []error{errors.New("requires at least 1 arg")}, nil), + }, + { + Description: "invalid identifier should fail", + Command: test.Command("volume", "inspect", "∞"), + Expected: test.Expects(1, []error{errdefs.ErrInvalidArgument}, nil), + }, + { + Description: "non existent volume should fail", + Command: test.Command("volume", "inspect", "doesnotexist"), + Expected: test.Expects(1, []error{errdefs.ErrNotFound}, nil), + }, + { + Description: "success", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", data.Get("vol1")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("vol1")), + func(stdout string, info string, t *testing.T) { + var dc []native.Volume + if err := json.Unmarshal([]byte(stdout), &dc); err != nil { + t.Fatal(err) + } + assert.Assert(t, len(dc) == 1, fmt.Sprintf("one result, not %d", len(dc))+info) + assert.Assert(t, dc[0].Name == data.Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.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) + }, + ), + } + }, + }, + { + Description: "inspect labels", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", data.Get("vol2")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("vol2")), + func(stdout string, info string, t *testing.T) { + var dc []native.Volume + if err := json.Unmarshal([]byte(stdout), &dc); err != nil { + t.Fatal(err) + } + 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"])) + assert.Assert(t, labels["bar"] == "barval", fmt.Sprintf("label bar should be barval, not %s", labels["bar"])) + }, + ), + } + }, + }, + { + Description: "inspect size", + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", "--size", data.Get("vol1")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("vol1")), + func(stdout string, info string, t *testing.T) { + var dc []native.Volume + if err := json.Unmarshal([]byte(stdout), &dc); err != nil { + t.Fatal(err) + } + assert.Assert(t, dc[0].Size == size, fmt.Sprintf("expected size to be %d (was %d)", size, dc[0].Size)) + }, + ), + } + }, + }, + { + Description: "multi success", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", data.Get("vol1"), data.Get("vol2")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.Contains(data.Get("vol1")), + test.Contains(data.Get("vol2")), + func(stdout string, info string, t *testing.T) { + var dc []native.Volume + if err := json.Unmarshal([]byte(stdout), &dc); err != nil { + t.Fatal(err) + } + assert.Assert(t, len(dc) == 2, fmt.Sprintf("two results, not %d", len(dc))) + assert.Assert(t, dc[0].Name == data.Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Get("vol1"), dc[0].Name)) + assert.Assert(t, dc[1].Name == data.Get("vol2"), fmt.Sprintf("expected name to be %q (was %q)", data.Get("vol2"), dc[1].Name)) + }, + ), + } + }, + }, + { + Description: "part success multi", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", "invalid∞", "nonexistent", data.Get("vol1")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{errdefs.ErrNotFound, errdefs.ErrInvalidArgument}, + Output: test.All( + test.Contains(data.Get("vol1")), + func(stdout string, info string, t *testing.T) { + var dc []native.Volume + if err := json.Unmarshal([]byte(stdout), &dc); err != nil { + t.Fatal(err) + } + assert.Assert(t, len(dc) == 1, fmt.Sprintf("one result, not %d", len(dc))) + assert.Assert(t, dc[0].Name == data.Get("vol1"), fmt.Sprintf("expected name to be %q (was %q)", data.Get("vol1"), dc[0].Name)) + }, + ), + } + }, + }, + { + Description: "multi failure", + Command: test.Command("volume", "inspect", "invalid∞", "nonexistent"), + Expected: test.Expects(1, []error{errdefs.ErrNotFound, errdefs.ErrInvalidArgument}, nil), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume_list.go b/cmd/nerdctl/volume/volume_list.go similarity index 82% rename from cmd/nerdctl/volume_list.go rename to cmd/nerdctl/volume/volume_list.go index 847eba092e0..891a626a09c 100644 --- a/cmd/nerdctl/volume_list.go +++ b/cmd/nerdctl/volume/volume_list.go @@ -14,14 +14,14 @@ limitations under the License. */ -package main +package volume import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/volume" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "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/volume" ) func newVolumeLsCommand() *cobra.Command { @@ -46,7 +46,7 @@ func newVolumeLsCommand() *cobra.Command { } func processVolumeLsOptions(cmd *cobra.Command) (types.VolumeListOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.VolumeListOptions{}, err } @@ -83,11 +83,3 @@ func volumeLsAction(cmd *cobra.Command, args []string) error { } return volume.List(options) } - -func getVolumes(cmd *cobra.Command, globalOptions types.GlobalCommandOptions) (map[string]native.Volume, error) { - volumeSize, err := cmd.Flags().GetBool("size") - if err != nil { - return nil, err - } - return volume.Volumes(globalOptions.Namespace, globalOptions.DataRoot, globalOptions.Address, volumeSize, nil) -} diff --git a/cmd/nerdctl/volume/volume_list_test.go b/cmd/nerdctl/volume/volume_list_test.go new file mode 100644 index 00000000000..d48b35809c3 --- /dev/null +++ b/cmd/nerdctl/volume/volume_list_test.go @@ -0,0 +1,401 @@ +/* + 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 volume + +import ( + "fmt" + "os" + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestVolumeLsSize(t *testing.T) { + nerdtest.Setup() + + tc := &test.Case{ + Require: test.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier("1")) + helpers.Ensure("volume", "create", data.Identifier("2")) + helpers.Ensure("volume", "create", data.Identifier("empty")) + vol1 := nerdtest.InspectVolume(helpers, data.Identifier("1")) + vol2 := nerdtest.InspectVolume(helpers, data.Identifier("2")) + + err := createFileWithSize(vol1.Mountpoint, 102400) + assert.NilError(t, err, "File creation failed") + err = createFileWithSize(vol2.Mountpoint, 204800) + assert.NilError(t, err, "File creation failed") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier("1")) + helpers.Anyhow("volume", "rm", "-f", data.Identifier("2")) + helpers.Anyhow("volume", "rm", "-f", data.Identifier("empty")) + }, + 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) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 4, "expected at least 4 lines"+info) + volSizes := map[string]string{ + data.Identifier("1"): "100.0 KiB", + data.Identifier("2"): "200.0 KiB", + data.Identifier("empty"): "0.0 B", + } + + var numMatches = 0 + var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") + var err = tab.ParseHeader(lines[0]) + assert.NilError(t, err, info) + + for _, line := range lines { + name, _ := tab.ReadRow(line, "VOLUME NAME") + size, _ := tab.ReadRow(line, "SIZE") + expectSize, ok := volSizes[name] + if !ok { + continue + } + assert.Assert(t, size == expectSize, fmt.Sprintf("expected size %s for volume %s, got %s", expectSize, name, size)+info) + numMatches++ + } + assert.Assert(t, numMatches == len(volSizes), fmt.Sprintf("expected %d volumes, got: %d", len(volSizes), numMatches)+info) + }, + } + }, + } + + tc.Run(t) +} + +func TestVolumeLsFilter(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Require = nerdtest.BrokenTest("This test assumes that the host-side of a volume can be written into, "+ + "which is not always true. To be replaced by cp into the container.", + &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + isDocker, _ := nerdtest.Docker.Check(data, helpers) + return !isDocker || os.Geteuid() == 0, "docker cli needs to be run as root" + }, + }) + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + var vol1, vol2, vol3, vol4 = data.Identifier("1"), data.Identifier("2"), data.Identifier("3"), data.Identifier("4") + var label1, label2, label3, label4 = "mylabel=label-1", "mylabel=label-2", "mylabel=label-3", "mylabel-group=label-4" + + helpers.Ensure("volume", "create", "--label="+label1, "--label="+label4, vol1) + helpers.Ensure("volume", "create", "--label="+label2, "--label="+label4, vol2) + helpers.Ensure("volume", "create", "--label="+label3, vol3) + helpers.Ensure("volume", "create", vol4) + + // FIXME + // This will not work with Docker rootful and Docker cli run as a user + // We should replace it with cp inside the container + err := createFileWithSize(nerdtest.InspectVolume(helpers, vol1).Mountpoint, 409600) + assert.NilError(t, err, "File creation failed") + err = createFileWithSize(nerdtest.InspectVolume(helpers, vol2).Mountpoint, 1024000) + assert.NilError(t, err, "File creation failed") + err = createFileWithSize(nerdtest.InspectVolume(helpers, vol3).Mountpoint, 409600) + assert.NilError(t, err, "File creation failed") + err = createFileWithSize(nerdtest.InspectVolume(helpers, vol4).Mountpoint, 1024000) + assert.NilError(t, err, "File creation failed") + + data.Set("vol1", vol1) + data.Set("vol2", vol2) + data.Set("vol3", vol3) + data.Set("vol4", vol4) + data.Set("mainlabel", "mylabel") + data.Set("label1", label1) + data.Set("label2", label2) + data.Set("label3", label3) + data.Set("label4", label4) + + } + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Get("vol1")) + helpers.Anyhow("volume", "rm", "-f", data.Get("vol2")) + helpers.Anyhow("volume", "rm", "-f", data.Get("vol3")) + helpers.Anyhow("volume", "rm", "-f", data.Get("vol4")) + } + testCase.SubTests = []*test.Case{ + { + Description: "No filter", + 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) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 4, "expected at least 4 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + data.Get("vol2"): {}, + data.Get("vol3"): {}, + data.Get("vol4"): {}, + } + var numMatches = 0 + for _, name := range lines { + _, ok := volNames[name] + if !ok { + continue + } + numMatches++ + } + assert.Assert(t, len(volNames) == numMatches, fmt.Sprintf("expected %d volumes, got: %d", len(volNames), numMatches)) + }, + } + }, + }, + { + Description: "Retrieving label=mainlabel", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "label="+data.Get("mainlabel")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + data.Get("vol2"): {}, + data.Get("vol3"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving label=mainlabel=label2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "label="+data.Get("label2")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1, "expected at least 1 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol2"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving label=mainlabel=", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "label="+data.Get("mainlabel")+"=") + }, + 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) + }, + } + }, + }, + { + Description: "Retrieving label=mainlabel=label1 and label=mainlabel=label2", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "label="+data.Get("label1"), "--filter", "label="+data.Get("label2")) + }, + 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) + }, + } + }, + }, + { + Description: "Retrieving label=mainlabel and label=grouplabel=label4", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "label="+data.Get("mainlabel"), "--filter", "label="+data.Get("label4")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 2, "expected at least 2 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + data.Get("vol2"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving name=volume1", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--quiet", "--filter", "name="+data.Get("vol1")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 1, "expected at least 1 line"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + 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.Get("vol1"), "--filter", "name="+data.Get("vol2")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 2, "expected at least 2 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + data.Get("vol2"): {}, + } + for _, name := range lines { + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving size=1024000", + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--size", "--filter", "size=1024000") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol2"): {}, + data.Get("vol4"): {}, + } + var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") + var err = tab.ParseHeader(lines[0]) + assert.NilError(t, err, "Tab reader failed") + for _, line := range lines { + + name, _ := tab.ReadRow(line, "VOLUME NAME") + if name == "VOLUME NAME" { + continue + } + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving size>=1024000 size<=2048000", + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--size", "--filter", "size>=1024000", "--filter", "size<=2048000") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol2"): {}, + data.Get("vol4"): {}, + } + var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") + var err = tab.ParseHeader(lines[0]) + assert.NilError(t, err, "Tab reader failed") + for _, line := range lines { + + name, _ := tab.ReadRow(line, "VOLUME NAME") + if name == "VOLUME NAME" { + continue + } + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + { + Description: "Retrieving size>204800 size<1024000", + Require: test.Not(nerdtest.Docker), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "ls", "--size", "--filter", "size>204800", "--filter", "size<1024000") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + var lines = strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 3, "expected at least 3 lines"+info) + volNames := map[string]struct{}{ + data.Get("vol1"): {}, + data.Get("vol3"): {}, + } + var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") + var err = tab.ParseHeader(lines[0]) + assert.NilError(t, err, "Tab reader failed") + for _, line := range lines { + + name, _ := tab.ReadRow(line, "VOLUME NAME") + if name == "VOLUME NAME" { + continue + } + _, ok := volNames[name] + assert.Assert(t, ok, fmt.Sprintf("unexpected volume %s found", name)+info) + } + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume/volume_namespace_test.go b/cmd/nerdctl/volume/volume_namespace_test.go new file mode 100644 index 00000000000..c2a2d6ce3db --- /dev/null +++ b/cmd/nerdctl/volume/volume_namespace_test.go @@ -0,0 +1,106 @@ +/* + 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 volume + +import ( + "testing" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestVolumeNamespace(t *testing.T) { + testCase := nerdtest.Setup() + + // Docker does not support namespaces + testCase.Require = test.Not(nerdtest.Docker) + + // Create a volume in a different namespace + testCase.Setup = func(data test.Data, helpers test.Helpers) { + data.Set("root_namespace", data.Identifier()) + data.Set("root_volume", data.Identifier()) + helpers.Ensure("--namespace", data.Identifier(), "volume", "create", data.Identifier()) + } + + // Cleanup once done + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Get("root_namespace") != "" { + helpers.Anyhow("--namespace", data.Identifier(), "volume", "remove", data.Identifier()) + helpers.Anyhow("namespace", "remove", data.Identifier()) + } + } + + testCase.SubTests = []*test.Case{ + { + Description: "inspect another namespace volume should fail", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "inspect", data.Get("root_volume")) + }, + Expected: test.Expects(1, []error{ + errdefs.ErrNotFound, + }, nil), + }, + { + Description: "removing another namespace volume should fail", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "remove", data.Get("root_volume")) + }, + Expected: test.Expects(1, []error{ + errdefs.ErrNotFound, + }, nil), + }, + { + Description: "prune should leave another namespace volume untouched", + // Make it private so that we do not interact with other tests in the main namespace + Require: nerdtest.Private, + Command: test.Command("volume", "prune", "-a", "-f"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain(data.Get("root_volume")), + func(stdout string, info string, t *testing.T) { + helpers.Ensure("--namespace", data.Get("root_namespace"), "volume", "inspect", data.Get("root_volume")) + }, + ), + } + }, + }, + { + Description: "create with the same name should work, then delete it", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "create", data.Get("root_volume")) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", data.Get("root_volume")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + helpers.Ensure("volume", "inspect", data.Get("root_volume")) + helpers.Ensure("volume", "rm", data.Get("root_volume")) + helpers.Ensure("--namespace", data.Get("root_namespace"), "volume", "inspect", data.Get("root_volume")) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume_prune.go b/cmd/nerdctl/volume/volume_prune.go similarity index 80% rename from cmd/nerdctl/volume_prune.go rename to cmd/nerdctl/volume/volume_prune.go index df0cbbd680b..cbc74fb882f 100644 --- a/cmd/nerdctl/volume_prune.go +++ b/cmd/nerdctl/volume/volume_prune.go @@ -14,16 +14,18 @@ limitations under the License. */ -package main +package volume import ( "fmt" "strings" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/volume" "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/volume" ) func newVolumePruneCommand() *cobra.Command { @@ -35,12 +37,18 @@ func newVolumePruneCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } + volumePruneCommand.Flags().BoolP("all", "a", false, "Remove all unused volumes, not just anonymous ones") volumePruneCommand.Flags().BoolP("force", "f", false, "Do not prompt for confirmation") return volumePruneCommand } func processVolumePruneOptions(cmd *cobra.Command) (types.VolumePruneOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.VolumePruneOptions{}, err + } + + all, err := cmd.Flags().GetBool("all") if err != nil { return types.VolumePruneOptions{}, err } @@ -52,6 +60,7 @@ func processVolumePruneOptions(cmd *cobra.Command) (types.VolumePruneOptions, er options := types.VolumePruneOptions{ GOptions: globalOptions, + All: all, Force: force, Stdout: cmd.OutOrStdout(), } diff --git a/cmd/nerdctl/volume/volume_prune_linux_test.go b/cmd/nerdctl/volume/volume_prune_linux_test.go new file mode 100644 index 00000000000..c33922c730b --- /dev/null +++ b/cmd/nerdctl/volume/volume_prune_linux_test.go @@ -0,0 +1,110 @@ +/* + 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 volume + +import ( + "strings" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestVolumePrune(t *testing.T) { + var setup = func(data test.Data, helpers test.Helpers) { + anonIDBusy := strings.TrimSpace(helpers.Capture("volume", "create")) + anonIDDangling := strings.TrimSpace(helpers.Capture("volume", "create")) + + namedBusy := data.Identifier("busy") + namedDangling := data.Identifier("free") + + helpers.Ensure("volume", "create", namedBusy) + helpers.Ensure("volume", "create", namedDangling) + helpers.Ensure("run", "--name", data.Identifier(), + "-v", namedBusy+":/namedbusyvolume", + "-v", anonIDBusy+":/anonbusyvolume", testutil.CommonImage) + + data.Set("anonIDBusy", anonIDBusy) + data.Set("anonIDDangling", anonIDDangling) + data.Set("namedBusy", namedBusy) + data.Set("namedDangling", namedDangling) + } + + var cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Get("anonIDBusy")) + helpers.Anyhow("volume", "rm", "-f", data.Get("anonIDDangling")) + helpers.Anyhow("volume", "rm", "-f", data.Get("namedBusy")) + helpers.Anyhow("volume", "rm", "-f", data.Get("namedDangling")) + } + + testCase := nerdtest.Setup() + // This set must be marked as private, since we cannot prune without interacting with other tests. + testCase.Require = nerdtest.Private + // Furthermore, these two subtests cannot be run in parallel + testCase.SubTests = []*test.Case{ + { + Description: "prune anonymous only", + NoParallel: true, + Setup: setup, + Cleanup: cleanup, + Command: test.Command("volume", "prune", "-f"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain(data.Get("anonIDBusy")), + test.Contains(data.Get("anonIDDangling")), + test.DoesNotContain(data.Get("namedBusy")), + test.DoesNotContain(data.Get("namedDangling")), + func(stdout string, info string, t *testing.T) { + helpers.Ensure("volume", "inspect", data.Get("anonIDBusy")) + helpers.Fail("volume", "inspect", data.Get("anonIDDangling")) + helpers.Ensure("volume", "inspect", data.Get("namedBusy")) + helpers.Ensure("volume", "inspect", data.Get("namedDangling")) + }, + ), + } + }, + }, + { + Description: "prune all", + NoParallel: true, + Setup: setup, + Cleanup: cleanup, + Command: test.Command("volume", "prune", "-f", "--all"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.All( + test.DoesNotContain(data.Get("anonIDBusy")), + test.Contains(data.Get("anonIDDangling")), + test.DoesNotContain(data.Get("namedBusy")), + test.Contains(data.Get("namedDangling")), + func(stdout string, info string, t *testing.T) { + helpers.Ensure("volume", "inspect", data.Get("anonIDBusy")) + helpers.Fail("volume", "inspect", data.Get("anonIDDangling")) + helpers.Ensure("volume", "inspect", data.Get("namedBusy")) + helpers.Fail("volume", "inspect", data.Get("namedDangling")) + }, + ), + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume_remove.go b/cmd/nerdctl/volume/volume_remove.go similarity index 84% rename from cmd/nerdctl/volume_remove.go rename to cmd/nerdctl/volume/volume_remove.go index 71245937b47..d4350b93cee 100644 --- a/cmd/nerdctl/volume_remove.go +++ b/cmd/nerdctl/volume/volume_remove.go @@ -14,13 +14,16 @@ limitations under the License. */ -package main +package volume import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/volume" "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/volume" ) func newVolumeRmCommand() *cobra.Command { @@ -40,7 +43,7 @@ func newVolumeRmCommand() *cobra.Command { } func processVolumeRmOptions(cmd *cobra.Command) (types.VolumeRemoveOptions, error) { - globalOptions, err := processRootCmdFlags(cmd) + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) if err != nil { return types.VolumeRemoveOptions{}, err } @@ -72,5 +75,5 @@ func volumeRmAction(cmd *cobra.Command, args []string) error { func volumeRmShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // show volume names - return shellCompleteVolumeNames(cmd) + return completion.VolumeNames(cmd) } diff --git a/cmd/nerdctl/volume/volume_remove_linux_test.go b/cmd/nerdctl/volume/volume_remove_linux_test.go new file mode 100644 index 00000000000..50bd936d6da --- /dev/null +++ b/cmd/nerdctl/volume/volume_remove_linux_test.go @@ -0,0 +1,230 @@ +/* + 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 volume + +import ( + "errors" + "fmt" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +// TestVolumeRemove does test a large variety of volume remove situations, albeit none of them being +// hard filesystem errors. +// Behavior in such cases is largely unspecified, as there is no easy way to compare with Docker. +// Anyhow, borked filesystem conditions is not something we should be expected to deal with in a smart way. +func TestVolumeRemove(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.SubTests = []*test.Case{ + { + Description: "arg missing should fail", + Command: test.Command("volume", "rm"), + Expected: test.Expects(1, []error{errors.New("requires at least 1 arg")}, nil), + }, + { + Description: "invalid identifier should fail", + Command: test.Command("volume", "rm", "∞"), + Expected: test.Expects(1, []error{errdefs.ErrInvalidArgument}, nil), + }, + { + Description: "non existent volume should fail", + Command: test.Command("volume", "rm", "doesnotexist"), + Expected: test.Expects(1, []error{errdefs.ErrNotFound}, nil), + }, + { + Description: "busy volume should fail", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier()) + helpers.Ensure("run", "-v", fmt.Sprintf("%s:/volume", data.Identifier()), + "--name", data.Identifier(), testutil.CommonImage) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", data.Identifier()) + }, + + Expected: test.Expects(1, []error{errdefs.ErrFailedPrecondition}, nil), + }, + { + Description: "busy anonymous volume should fail", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-v", "/volume", "--name", data.Identifier(), testutil.CommonImage) + // Inspect the container and find the anonymous volume id + inspect := nerdtest.InspectContainer(helpers, data.Identifier()) + var anonName string + for _, v := range inspect.Mounts { + if v.Destination == "/volume" { + anonName = v.Name + break + } + } + assert.Assert(t, anonName != "", "Failed to find anonymous volume id", inspect) + data.Set("anonName", anonName) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Get("anonName")) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // Try to remove that anon volume + return helpers.Command("volume", "rm", data.Get("anonName")) + }, + + Expected: test.Expects(1, []error{errdefs.ErrFailedPrecondition}, nil), + }, + { + Description: "freed volume should succeed", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier()) + helpers.Ensure("run", "-v", fmt.Sprintf("%s:/volume", data.Identifier()), "--name", data.Identifier(), testutil.CommonImage) + helpers.Ensure("rm", "-f", data.Identifier()) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", data.Identifier()) + }, + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + { + Description: "dangling volume should succeed", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier()) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", data.Identifier()) + }, + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + { + Description: "part success multi-remove", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier()) + helpers.Ensure("volume", "create", data.Identifier("busy")) + helpers.Ensure("run", "-v", fmt.Sprintf("%s:/volume", data.Identifier("busy")), "--name", data.Identifier(), testutil.CommonImage) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier("busy")) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", "invalid∞", "nonexistent", data.Identifier("busy"), data.Identifier()) + }, + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{ + errdefs.ErrNotFound, + errdefs.ErrFailedPrecondition, + errdefs.ErrInvalidArgument, + }, + Output: test.Equals(data.Identifier() + "\n"), + } + }, + }, + { + Description: "success multi-remove", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier("1")) + helpers.Ensure("volume", "create", data.Identifier("2")) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("volume", "rm", "-f", data.Identifier("1"), data.Identifier("2")) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", data.Identifier("1"), data.Identifier("2")) + }, + + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: test.Equals(data.Identifier("1") + "\n" + data.Identifier("2") + "\n"), + } + }, + }, + { + Description: "failing multi-remove", + + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("volume", "create", data.Identifier("busy")) + helpers.Ensure("run", "-v", fmt.Sprintf("%s:/volume", data.Identifier("busy")), "--name", data.Identifier(), testutil.CommonImage) + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier("busy")) + }, + + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("volume", "rm", "invalid∞", "nonexistent", data.Identifier("busy")) + }, + + Expected: test.Expects(1, []error{ + errdefs.ErrNotFound, + errdefs.ErrFailedPrecondition, + errdefs.ErrInvalidArgument, + }, nil), + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/volume/volume_test.go b/cmd/nerdctl/volume/volume_test.go new file mode 100644 index 00000000000..980319ec60a --- /dev/null +++ b/cmd/nerdctl/volume/volume_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 volume + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func TestMain(m *testing.M) { + testutil.M(m) +} diff --git a/cmd/nerdctl/volume_create_test.go b/cmd/nerdctl/volume_create_test.go deleted file mode 100644 index 742810b0223..00000000000 --- a/cmd/nerdctl/volume_create_test.go +++ /dev/null @@ -1,58 +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 main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -// Test TestVolumeCreate for creating volume with given name. -func TestVolumeCreate(t *testing.T) { - base := testutil.NewBase(t) - testVolume := testutil.Identifier(t) - - base.Cmd("volume", "create", testVolume).AssertOK() - defer base.Cmd("volume", "rm", "-f", testVolume).Run() - - base.Cmd("volume", "list").AssertOutContains(testVolume) -} - -// Test TestVolumeCreateTooManyArgs for creating volume with too many args. -func TestVolumeCreateTooManyArgs(t *testing.T) { - base := testutil.NewBase(t) - - base.Cmd("volume", "create", "too", "many").AssertFail() -} - -// Test TestVolumeCreateWithLabels for creating volume with given labels. -func TestVolumeCreateWithLabels(t *testing.T) { - base := testutil.NewBase(t) - testVolume := testutil.Identifier(t) - - base.Cmd("volume", "create", testVolume, "--label", "foo1=baz1", "--label", "foo2=baz2").AssertOK() - defer base.Cmd("volume", "rm", "-f", testVolume).Run() - - inspect := base.InspectVolume(testVolume) - inspectNerdctlLabels := *inspect.Labels - expected := make(map[string]string, 2) - expected["foo1"] = "baz1" - expected["foo2"] = "baz2" - assert.DeepEqual(base.T, expected, inspectNerdctlLabels) -} diff --git a/cmd/nerdctl/volume_inspect_test.go b/cmd/nerdctl/volume_inspect_test.go deleted file mode 100644 index 6dc948b31cd..00000000000 --- a/cmd/nerdctl/volume_inspect_test.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 main - -import ( - "crypto/rand" - "os" - "path/filepath" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" -) - -func TestVolumeInspectContainsLabels(t *testing.T) { - t.Parallel() - testVolume := testutil.Identifier(t) - - base := testutil.NewBase(t) - base.Cmd("volume", "create", "--label", "tag=testVolume", testVolume).AssertOK() - defer base.Cmd("volume", "rm", "-f", testVolume).Run() - - inspect := base.InspectVolume(testVolume) - inspectNerdctlLabels := (*inspect.Labels) - expected := make(map[string]string, 1) - expected["tag"] = "testVolume" - assert.DeepEqual(base.T, expected, inspectNerdctlLabels) -} - -func TestVolumeInspectSize(t *testing.T) { - testutil.DockerIncompatible(t) - t.Parallel() - testVolume := testutil.Identifier(t) - base := testutil.NewBase(t) - base.Cmd("volume", "create", testVolume).AssertOK() - defer base.Cmd("volume", "rm", "-f", testVolume).Run() - - var size int64 = 1028 - createFileWithSize(t, testVolume, size) - volumeWithSize := base.InspectVolume(testVolume, []string{"--size"}...) - assert.Equal(t, volumeWithSize.Size, size) -} - -func createFileWithSize(t *testing.T, volume string, bytes int64) { - base := testutil.NewBase(t) - v := base.InspectVolume(volume) - token := make([]byte, bytes) - rand.Read(token) - err := os.WriteFile(filepath.Join(v.Mountpoint, "test-file"), token, 0644) - assert.NilError(t, err) -} diff --git a/cmd/nerdctl/volume_list_test.go b/cmd/nerdctl/volume_list_test.go deleted file mode 100644 index 9a62dd102d4..00000000000 --- a/cmd/nerdctl/volume_list_test.go +++ /dev/null @@ -1,373 +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 main - -import ( - "errors" - "fmt" - "strings" - "testing" - - "github.com/containerd/nerdctl/pkg/tabutil" - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestVolumeLs(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - testutil.DockerIncompatible(t) - - var vol1, vol2, vol3 = tID + "vol-1", tID + "vol-2", tID + "empty" - base.Cmd("volume", "create", vol1).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol1).Run() - - base.Cmd("volume", "create", vol2).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol2).Run() - - base.Cmd("volume", "create", vol3).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol3).Run() - - createFileWithSize(t, vol1, 102400) - createFileWithSize(t, vol2, 204800) - - base.Cmd("volume", "ls", "--size").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 4 { - return errors.New("expected at least 4 lines") - } - volSizes := map[string]string{ - vol1: "100.0 KiB", - vol2: "200.0 KiB", - vol3: "0.0 B", - } - - var numMatches = 0 - var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") - var err = tab.ParseHeader(lines[0]) - if err != nil { - return err - } - for _, line := range lines { - name, _ := tab.ReadRow(line, "VOLUME NAME") - size, _ := tab.ReadRow(line, "SIZE") - expectSize, ok := volSizes[name] - if !ok { - continue - } - if size != expectSize { - return fmt.Errorf("expected size %s for volume %s, got %s", expectSize, name, size) - } - numMatches++ - } - if len(volSizes) != numMatches { - return fmt.Errorf("expected %d volumes, got: %d", len(volSizes), numMatches) - } - return nil - }) - -} - -func TestVolumeLsFilter(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - var vol1, vol2, vol3, vol4 = tID + "vol-1", tID + "vol-2", tID + "vol-3", tID + "vol-4" - var label1, label2, label3, label4 = tID + "=label-1", tID + "=label-2", tID + "=label-3", tID + "-group=label-4" - base.Cmd("volume", "create", "--label="+label1, "--label="+label4, vol1).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol1).Run() - - base.Cmd("volume", "create", "--label="+label2, "--label="+label4, vol2).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol2).Run() - - base.Cmd("volume", "create", "--label="+label3, vol3).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol3).Run() - - base.Cmd("volume", "create", vol4).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol4).Run() - - base.Cmd("volume", "ls", "--quiet").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 4 { - return errors.New("expected at least 4 lines") - } - volNames := map[string]struct{}{ - vol1: {}, - vol2: {}, - vol3: {}, - vol4: {}, - } - - var numMatches = 0 - for _, name := range lines { - _, ok := volNames[name] - if !ok { - continue - } - numMatches++ - } - if len(volNames) != numMatches { - return fmt.Errorf("expected %d volumes, got: %d", len(volNames), numMatches) - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "label="+tID).AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 3 { - return errors.New("expected at least 3 lines") - } - volNames := map[string]struct{}{ - vol1: {}, - vol2: {}, - vol3: {}, - } - - for _, name := range lines { - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "label="+label2).AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 1 { - return errors.New("expected at least 1 lines") - } - volNames := map[string]struct{}{ - vol2: {}, - } - - for _, name := range lines { - if name == "" { - continue - } - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "label="+tID+"=").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) > 0 { - for _, name := range lines { - if name != "" { - return fmt.Errorf("unexpected volumes %d found", len(lines)) - } - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "label="+label1, "--filter", "label="+label2).AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) > 0 { - for _, name := range lines { - if name != "" { - return fmt.Errorf("unexpected volumes %d found", len(lines)) - } - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "label="+tID, "--filter", "label="+label4).AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 2 { - return errors.New("expected at least 2 lines") - } - volNames := map[string]struct{}{ - vol1: {}, - vol2: {}, - } - - for _, name := range lines { - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "name="+vol1).AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 1 { - return errors.New("expected at least 1 lines") - } - volNames := map[string]struct{}{ - vol1: {}, - } - - for _, name := range lines { - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "name=vol-3").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 1 { - return errors.New("expected at least 1 lines") - } - volNames := map[string]struct{}{ - vol3: {}, - } - - for _, name := range lines { - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--quiet", "--filter", "name=vol2", "--filter", "name=vol1").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) > 0 { - for _, name := range lines { - if name != "" { - return fmt.Errorf("unexpected volumes %d found", len(lines)) - } - } - } - return nil - }) -} - -func TestVolumeLsFilterSize(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - testutil.DockerIncompatible(t) - - var vol1, vol2, vol3, vol4 = tID + "volsize-1", tID + "volsize-2", tID + "volsize-3", tID + "volsize-4" - var label1, label2, label3, label4 = tID + "=label-1", tID + "=label-2", tID + "=label-3", tID + "-group=label-4" - base.Cmd("volume", "create", "--label="+label1, "--label="+label4, vol1).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol1).Run() - - base.Cmd("volume", "create", "--label="+label2, "--label="+label4, vol2).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol2).Run() - - base.Cmd("volume", "create", "--label="+label3, vol3).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol3).Run() - - base.Cmd("volume", "create", vol4).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol4).Run() - - createFileWithSize(t, vol1, 409600) - createFileWithSize(t, vol2, 1024000) - createFileWithSize(t, vol3, 409600) - createFileWithSize(t, vol4, 1024000) - - base.Cmd("volume", "ls", "--size", "--filter", "size=1024000").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 3 { - return errors.New("expected at least 3 lines") - } - - var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") - var err = tab.ParseHeader(lines[0]) - if err != nil { - return err - } - volNames := map[string]struct{}{ - vol2: {}, - vol4: {}, - } - - for _, line := range lines { - name, _ := tab.ReadRow(line, "VOLUME NAME") - if name == "VOLUME NAME" { - continue - } - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--size", "--filter", "size>=1024000", "--filter", "size<=2048000").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 3 { - return errors.New("expected at least 3 lines") - } - - var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") - var err = tab.ParseHeader(lines[0]) - if err != nil { - return err - } - volNames := map[string]struct{}{ - vol2: {}, - vol4: {}, - } - - for _, line := range lines { - name, _ := tab.ReadRow(line, "VOLUME NAME") - if name == "VOLUME NAME" { - continue - } - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) - - base.Cmd("volume", "ls", "--size", "--filter", "size>204800", "--filter", "size<1024000").AssertOutWithFunc(func(stdout string) error { - var lines = strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) < 3 { - return errors.New("expected at least 3 lines") - } - - var tab = tabutil.NewReader("VOLUME NAME\tDIRECTORY\tSIZE") - var err = tab.ParseHeader(lines[0]) - if err != nil { - return err - } - volNames := map[string]struct{}{ - vol1: {}, - vol3: {}, - } - - for _, line := range lines { - name, _ := tab.ReadRow(line, "VOLUME NAME") - if name == "VOLUME NAME" { - continue - } - _, ok := volNames[name] - if !ok { - return fmt.Errorf("unexpected volume %s found", name) - } - } - return nil - }) -} diff --git a/cmd/nerdctl/volume_prune_linux_test.go b/cmd/nerdctl/volume_prune_linux_test.go deleted file mode 100644 index e9a53b064f0..00000000000 --- a/cmd/nerdctl/volume_prune_linux_test.go +++ /dev/null @@ -1,44 +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 main - -import ( - "fmt" - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestVolumePrune(t *testing.T) { - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - base.Cmd("volume", "prune", "-f").Run() - - base.Cmd("volume", "create", tID+"-1").AssertOK() - base.Cmd("volume", "create", tID+"-2").AssertOK() - - base.Cmd("run", "-v", fmt.Sprintf("%s:/volume", tID+"-1"), "--name", tID, testutil.CommonImage).AssertOK() - defer base.Cmd("rm", "-f", tID).Run() - - base.Cmd("volume", "prune", "-f").AssertOutContains(tID + "-2") - base.Cmd("volume", "ls").AssertOutContains(tID + "-1") - base.Cmd("volume", "ls").AssertNoOut(tID + "-2") - - base.Cmd("rm", "-f", tID).AssertOK() - base.Cmd("volume", "prune", "-f").AssertOK() - base.Cmd("volume", "ls").AssertNoOut(tID + "-1") -} diff --git a/docs/cni.md b/docs/cni.md index f404e6405fc..67313891604 100644 --- a/docs/cni.md +++ b/docs/cni.md @@ -61,10 +61,7 @@ Configuration of the default network `bridge` of Linux: nerdctl >= 0.18 sets the `ingressPolicy` to `same-bridge` when `firewall` plugin >= 1.1.0 is installed. This `ingressPolicy` replaces the CNI `isolation` plugin used in nerdctl <= 0.17. -When the `isolation` plugin is found, nerdctl uses the `isolation` plugin instead of `ingressPolicy`. -The `isolation` plugin has been deprecated, and a future version of `nerdctl` will solely support `ingressPolicy`. - -When neither of `firewall` plugin >= 1.1.0 or `isolation` plugin is found, nerdctl does not enable the bridge isolation. +When `firewall` plugin >= 1.1.0 is not found, nerdctl does not enable the bridge isolation. This means a container in `--net=foo` can connect to a container in `--net=bar`. ## macvlan/IPvlan networks @@ -93,10 +90,42 @@ an easier way is to use DHCP to assign the IP: Using `--driver ipvlan` can create `ipvlan` network, the default mode for IPvlan is `l2`. +## DHCP host-name and other DHCP options + +Nerdctl automatically sets the DHCP host-name option to the hostname value of the container. + +Furthermore, on network creation, nerdctl supports the ability to set other DHCP options through `--ipam-options`. + +Currently, the following options are supported by the DHCP plugin: +``` +dhcp-client-identifier +subnet-mask +routers +user-class +vendor-class-identifier +``` + +For example: +``` +# nerdctl network create --driver macvlan \ + --ipam-driver dhcp \ + --ipam-opt 'vendor-class-identifier={"type": "provide", "value": "Hey! Its me!"}' \ + my-dhcp-net +``` + ## Custom networks You can also customize your CNI network by providing configuration files. -For example you have one configuration file(`/etc/cni/net.d/10-mynet.conf`) + +When rootful, the expected root location is `/etc/cni/net.d`. +For rootless, the expected root location is `~/.config/cni/net.d/` + +Configuration files (like `10-mynet.conf`) can be placed either in the root location, +or under a subfolder. +If in the root location, this network will be available to all nerdctl namespaces. +If placed in a subfolder, it will be available only to the identically named namespace. + +For example, you have one configuration file(`/etc/cni/net.d/10-mynet.conf`) for `bridge` network: ```json @@ -118,7 +147,7 @@ for `bridge` network: ``` This will configure a new CNI network with the name `mynet`, and you can use -this network to create a container: +this network to create a container in any namespace: ```console # nerdctl run -it --net mynet --rm alpine ip addr show diff --git a/docs/command-reference.md b/docs/command-reference.md index 0e9c7c644d8..4284094d176 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -31,7 +31,9 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl pause](#whale-nerdctl-pause) - [:whale: nerdctl unpause](#whale-nerdctl-unpause) - [:whale: nerdctl rename](#whale-nerdctl-rename) + - [:whale: nerdctl attach](#whale-nerdctl-attach) - [:whale: nerdctl container prune](#whale-nerdctl-container-prune) + - [:whale: nerdctl diff](#whale-nerdctl-diff) - [Build](#build) - [:whale: nerdctl build](#whale-nerdctl-build) - [:whale: nerdctl commit](#whale-nerdctl-commit) @@ -109,6 +111,7 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl compose pause](#whale-nerdctl-compose-pause) - [:whale: nerdctl compose unpause](#whale-nerdctl-compose-unpause) - [:whale: nerdctl compose config](#whale-nerdctl-compose-config) + - [:whale: nerdctl compose cp](#whale-nerdctl-compose-cp) - [:whale: nerdctl compose kill](#whale-nerdctl-compose-kill) - [:whale: nerdctl compose restart](#whale-nerdctl-compose-restart) - [:whale: nerdctl compose rm](#whale-nerdctl-compose-rm) @@ -131,12 +134,15 @@ Run a command in a new container. Usage: `nerdctl run [OPTIONS] IMAGE [COMMAND] [ARG...]` :nerd_face: `ipfs://` prefix can be used for `IMAGE` to pull it from IPFS. See [`ipfs.md`](./ipfs.md) for details. +:nerd_face: `oci-archive://` prefix can be used for `IMAGE` to specify a local file system path to an OCI formatted tarball. 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 - :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: `--restart=(no|always|on-failure|unless-stopped)`: Restart policy to apply when a container exits - Default: "no" @@ -146,6 +152,7 @@ Basic flags: - :whale: `--rm`: Automatically remove the container when it exits - :whale: `--pull=(always|missing|never)`: Pull image before running - Default: "missing" +- :whale: `-q, --quiet`: Suppress the pull output - :whale: `--pid=(host|container:)`: PID namespace to use - :whale: `--uts=(host)` : UTS namespace to use - :whale: `--stop-signal`: Signal to stop a container (default "SIGTERM") @@ -169,9 +176,10 @@ Isolation flags: Network flags: -- :whale: `--net, --network=(bridge|host|none|container:|)`: Connect a container to a network. +- :whale: `--net, --network=(bridge|host|none|container:|ns:|)`: Connect a container to a network. - Default: "bridge" - - 'container:': reuse another container's network stack, container has to be precreated. + - `container:`: reuse another container's network stack, container has to be precreated. + - :nerd_face: `ns:`: run inside an existing network namespace - :nerd_face: Unlike Docker, this flag can be specified multiple times (`--net foo --net bar`) - :whale: `-p, --publish`: Publish a container's port(s) to the host - :whale: `--dns`: Set custom DNS servers @@ -180,7 +188,8 @@ Network flags: - :whale: `-h, --hostname`: Container host name - :whale: `--add-host`: Add a custom host-to-IP mapping (host:ip). `ip` could be a special string `host-gateway`, - which will be resolved to the `host-gateway-ip` in nerdctl.toml or global flag. -- :whale: `--ip`: Specific static IP address(es) to use +- :whale: `--ip`: Specific static IP address(es) to use. Note that unlike docker, nerdctl allows specifying it with the default bridge network. +- :whale: `--ip6`: Specific static IP6 address(es) to use. Should be used with user networks - :whale: `--mac-address`: Specific MAC address to use. Be aware that it does not check if manually specified MAC addresses are unique. Supports network type `bridge` and `macvlan` @@ -224,10 +233,20 @@ Security flags: - :whale: `--security-opt seccomp=`: specify custom seccomp profile - :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 - :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 - :whale: `--privileged`: Give extended privileges to this container +- :nerd_face: `--systemd=(true|false|always)`: Enable systemd compatibility (default: false). + - Default: "false" + - true: Enable systemd compatibility is enabled if the entrypoint executable matches one of the following paths: + - `/sbin/init` + - `/usr/sbin/init` + - `/usr/local/sbin/init` + - always: Always enable systemd compatibility + +Corresponds to Podman CLI. Runtime flags: @@ -251,12 +270,12 @@ Volume flags: consisting of a `=` tuple. e.g., `-- mount type=bind,source=/src,target=/app,bind-propagation=shared`. - :whale: `type`: Current supported mount types are `bind`, `volume`, `tmpfs`. - The defaul type will be set to `volume` if not specified. - i.e., `--mount src=vol-1,dst=/app,readonly` equals `--mount type=volum,src=vol-1,dst=/app,readonly` + The default type will be set to `volume` if not specified. + i.e., `--mount src=vol-1,dst=/app,readonly` equals `--mount type=volume,src=vol-1,dst=/app,readonly` - Common Options: - :whale: `src`, `source`: Mount source spec for bind and volume. Mandatory for bind. - :whale: `dst`, `destination`, `target`: Mount destination spec. - - :whale: `readonly`, `ro`, `rw`, `rro`: Filesystem permissinos. + - :whale: `readonly`, `ro`, `rw`, `rro`: Filesystem permissions. - Options specific to `bind`: - :whale: `bind-propagation`: `shared`, `slave`, `private`, `rshared`, `rslave`, or `rprivate`(default). - :whale: `bind-nonrecursive`: `true` or `false`(default). If set to true, submounts are not recursively bind-mounted. This option is useful for readonly bind mount. @@ -267,6 +286,7 @@ Volume flags: Defaults to `1777` or world-writable. - Options specific to `volume`: - unimplemented options: `volume-nocopy`, `volume-label`, `volume-driver`, `volume-opt` +- :whale: `--volumes-from`: Mount volumes from the specified container(s), e.g. "--volumes-from my-container". Rootfs flags: @@ -284,8 +304,9 @@ Env flags: Metadata flags: - :whale: :blue_square: `--name`: Assign a name to the container -- :whale: :blue_square: `-l, --label`: Set meta data on a 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 - :nerd_face: `--pidfile`: file path to write the task's pid. The CLI syntax conforms to Podman convention. @@ -346,7 +367,7 @@ Logging flags: Shared memory flags: -- :whale: `--ipc`: IPC namespace to use +- :whale: `--ipc=(host|private|shareable|container:)`: IPC namespace to use and mount `/dev/shm`. Default: "private". Only implemented on Linux. - :whale: `--shm-size`: Size of `/dev/shm` GPU flags: @@ -357,6 +378,26 @@ Ulimit flags: - :whale: `--ulimit`: Set ulimit +--ulimit can be used to restrict the following types of resources. + +| type | describe| value range | +|----|----|----| +| core | limits the core file size (KB)| A 64-bit integer (INT64), with no units. It can be 0, negative, where -1 represents UNLIMITED (i.e., no limit is applied), and any other negative values will be forcibly converted to a large positive integer.| +| cpu | max CPU time (MIN)| same as above| +| data |max data size (KB) | same as above| +| fsize | maximum filesize (KB)| same as above| +| locks | max number of file locks the user can hold | same as above| +| memlock | max locked-in-memory address space (KB) | same as above| +| msgqueue | max memory used by POSIX message queues (bytes)| same as above| +| nice | nice priority | same as above | +| nproc | max number of processes | same as above| +| rss | max resident set size (KB)| same as above| +| rtprio | max realtime priority| same as above| +| rttime | realtime timeout | same as above| +| sigpending | max number of pending signals| same as above| +| stack | max stack size (KB) | same as above| +| nofile | max number of open file descriptors| A 64-bit integer (int64), with no units. It cannot be negative; negative values will be forcibly converted to a large number, and an "Operation not permitted" error will occur during setting| + Verify flags: - :nerd_face: `--verify`: Verify the image (none|cosign|notation). See [`./cosign.md`](./cosign.md) and [`./notation.md`](./notation.md) for details. @@ -371,10 +412,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: - `--attach`, `--blkio-weight-device`, `--cpu-rt-*`, `--device-*`, - `--disable-content-trust`, `--domainname`, `--expose`, `--health-*`, `--ip6`, `--isolation`, `--no-healthcheck`, - `--link*`, `--mac-address`, `--publish-all`, `--sig-proxy`, `--storage-opt`, - `--userns`, `--volume-driver`, `--volumes-from` + `--blkio-weight-device`, `--cpu-rt-*`, `--device-*`, + `--disable-content-trust`, `--domainname`, `--expose`, `--health-*`, `--isolation`, `--no-healthcheck`, + `--link*`, `--publish-all`, `--storage-opt`, + `--userns`, `--volume-driver` ### :whale: :blue_square: nerdctl exec @@ -403,6 +444,7 @@ Create a new container. Usage: `nerdctl create [OPTIONS] IMAGE [COMMAND] [ARG...]` :nerd_face: `ipfs://` prefix can be used for `IMAGE` to pull it from IPFS. See [`ipfs.md`](./ipfs.md) for details. +:nerd_face: `oci-archive://` prefix can be used for `IMAGE` to specify a local file system path to an OCI formatted tarball. The `nerdctl create` command similar to `nerdctl run -d` except the container is never started. You can then use the `nerdctl start ` command to start the container at any point. @@ -443,7 +485,7 @@ Flags: - :nerd_face: `--format=json`: Alias of `--format='{{json .}}'` - :whale: `-n, --last`: Show n last created containers (includes all states) - :whale: `-l, --latest`: Show the latest created container (includes all states) -- :whale: `-f, --filter`: Filter containers based on given conditions +- :whale: `-f, --filter`: Filter containers based on given conditions. When specifying the condition 'status', it filters all containers - :whale: `--filter id=`: Container's ID. Both full ID and truncated ID are supported - :whale: `--filter name=`: Container's name @@ -453,7 +495,7 @@ Flags: `--all` - :whale: `--filter status=`: One of `created, running, paused, stopped, exited, pausing, unknown`. Note that `restarting, removing, dead` are - not supported and will be ignored + not supported and will be ignored. When specifying this condition, it filters all containers. - :whale: `--filter before/since=`: Filter containers created before or after a given ID or name - :whale: `--filter volume=`: Filter by a given mounted volume or bind @@ -479,6 +521,7 @@ Flags: - :nerd_face: `--mode=(dockercompat|native)`: Inspection mode. "native" produces more information. - :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}` - :whale: `--type`: Return JSON for specified type +- :whale: `--size`: Display total file sizes if the type is container Unimplemented `docker inspect` flags: `--size` @@ -608,6 +651,30 @@ Rename a container. Usage: `nerdctl rename CONTAINER NEW_NAME` +### :whale: nerdctl attach + +Attach stdin, stdout, and stderr to a running container. For example: + +1. `nerdctl run -it --name test busybox` to start a container with a pty +2. `ctrl-p ctrl-q` to detach from the container +3. `nerdctl attach test` to attach to the container + +Caveats: + +- Currently only one attach session is allowed. When the second session tries to attach, currently no error will be returned from nerdctl. + However, since behind the scenes, there's only one FIFO for stdin, stdout, and stderr respectively, + if there are multiple sessions, all the sessions will be reading from and writing to the same 3 FIFOs, which will result in mixed input and partial output. +- Until dual logging (issue #1946) is implemented, + a container that is spun up by either `nerdctl run -d` or `nerdctl start` (without `--attach`) cannot be attached to. + +Usage: `nerdctl attach CONTAINER` + +Flags: + +- :whale: `--detach-keys`: Override the default detach keys + +Unimplemented `docker attach` flags: `--no-stdin`, `--sig-proxy` + ### :whale: nerdctl container prune Remove all stopped containers. @@ -620,6 +687,12 @@ Flags: Unimplemented `docker container prune` flags: `--filter` +### :whale: nerdctl diff + +Inspect changes to files or directories on a container's filesystem + +Usage: `nerdctl diff CONTAINER` + ## Build ### :whale: nerdctl build @@ -645,17 +718,25 @@ Flags: - :whale: `type=tar[,dest=path/to/output.tar]`: Raw tar ball - :whale: `type=image,name=example.com/image,push=true`: Push to a registry (see [`buildctl build`](https://github.com/moby/buildkit/tree/v0.9.0#imageregistry) documentation) - :whale: `--progress=(auto|plain|tty)`: Set type of progress output (auto, plain, tty). Use plain to show container output +- :whale: `--provenance`: Shorthand for \"--attest=type=provenance\", see [`buildx_build.md`](https://github.com/docker/buildx/blob/v0.12.1/docs/reference/buildx_build.md#provenance) documentation +- :whale: `--pull=(true|false)`: On true, always attempt to pull latest image version from remote. Default uses buildkit's default. - :whale: `--secret`: Secret file to expose to the build: id=mysecret,src=/local/secret +- :whale: `--allow`: Allow extra privileged entitlement, e.g. network.host, security.insecure (It’s required to configure the buildkitd to enable the feature, see [`buildkitd.toml`](https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md) documentation) +- :whale: `--attest`: Attestation parameters (format: "type=sbom,generator=image"), see [`buildx_build.md`](https://github.com/docker/buildx/blob/v0.12.1/docs/reference/buildx_build.md#attest) documentation - :whale: `--ssh`: SSH agent socket or keys to expose to the build (format: `default|[=|[,]]`) - :whale: `-q, --quiet`: Suppress the build output and print image ID on success +- :whale: `--sbom`: Shorthand for \"--attest=type=sbom\", see [`buildx_build.md`](https://github.com/docker/buildx/blob/v0.12.1/docs/reference/buildx_build.md#sbom) documentation - :whale: `--cache-from=CACHE`: External cache sources (eg. user/app:cache, type=local,src=path/to/dir) (compatible with `docker buildx build`) - :whale: `--cache-to=CACHE`: Cache export destinations (eg. user/app:cache, type=local,dest=path/to/dir) (compatible with `docker buildx build`) - :whale: `--platform=(amd64|arm64|...)`: Set target platform for build (compatible with `docker buildx build`) - :whale: `--iidfile=FILE`: Write the image ID to the file - :nerd_face: `--ipfs`: Build image with pulling base images from IPFS. See [`ipfs.md`](./ipfs.md) for details. - :whale: `--label`: Set metadata for an image +- :whale: `--network=(default|host|none)`: Set the networking mode for the RUN instructions during build.(compatible with `buildctl build`) +- :whale: `--build-context`: Set additional contexts for build (e.g. dir2=/path/to/dir2, myorg/myapp=docker-image://path/to/myorg/myapp) +- :whale: `--add-host`: Add a custom host-to-IP mapping (format: `host:ip`) -Unimplemented `docker build` flags: `--add-host`, `--network`, `--squash` +Unimplemented `docker build` flags: `--squash` ### :whale: nerdctl commit @@ -691,7 +772,7 @@ Flags: - :nerd_face: `--format=wide`: Wide table - :nerd_face: `--format=json`: Alias of `--format='{{json .}}'` - :whale: `--digests`: Show digests (compatible with Docker, unlike ID) -- :whale: `-f, --filter`: Filter the images. For now, only 'before=' and 'since=' is supported. +- :whale: `-f, --filter`: Filter the images. - :whale: `--filter=before=`: Images created before given image (exclusive) - :whale: `--filter=since=`: Images created after given image (exclusive) - :whale: `--filter=label=`: Matches images based on the presence of a label alone or a label and a value @@ -721,6 +802,7 @@ Flags: - :nerd_face: `--cosign-certificate-oidc-issuer`: The OIDC issuer expected in a valid Fulcio certificate for --verify=cosign,, e.g. https://token.actions.githubusercontent.com or https://oauth2.sigstore.dev/auth. Either --cosign-certificate-oidc-issuer or --cosign-certificate-oidc-issuer-regexp must be set for keyless flows - :nerd_face: `--cosign-certificate-oidc-issuer-regexp`: A regular expression alternative to --certificate-oidc-issuer for --verify=cosign,. Accepts the Go regular expression syntax described at https://golang.org/s/re2syntax. Either --cosign-certificate-oidc-issuer or --cosign-certificate-oidc-issuer-regexp must be set for keyless flows - :nerd_face: `--ipfs-address`: Multiaddr of IPFS API (default uses `$IPFS_PATH` env variable if defined or local directory `~/.ipfs`) +- :nerd_face: `--soci-index-digest`: Specify a particular index digest for SOCI. If left empty, SOCI will automatically use the index determined by the selection policy. Unimplemented `docker pull` flags: `--all-tags`, `--disable-content-trust` (default true) @@ -741,8 +823,11 @@ Flags: - :nerd_face: `--notation-key-name`: Signing key name for a key previously added to notation's key list for `--sign=notation` - :nerd_face: `--allow-nondistributable-artifacts`: Allow pushing images with non-distributable blobs - :nerd_face: `--ipfs-address`: Multiaddr of IPFS API (default uses `$IPFS_PATH` env variable if defined or local directory `~/.ipfs`) +- :whale: `-q, --quiet`: Suppress verbose output +- :nerd_face: `--soci-span-size`: Span size in bytes that soci index uses to segment layer data. Default is 4 MiB. +- :nerd_face: `--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. -Unimplemented `docker push` flags: `--all-tags`, `--disable-content-trust` (default true), `--quiet` +Unimplemented `docker push` flags: `--all-tags`, `--disable-content-trust` (default true) ### :whale: nerdctl load @@ -755,11 +840,10 @@ Usage: `nerdctl load [OPTIONS]` Flags: - :whale: `-i, --input`: Read from tar archive file, instead of STDIN +- :whale: `-q, --quiet`: Suppress the load output - :nerd_face: `--platform=(amd64|arm64|...)`: Import content for a specific platform - :nerd_face: `--all-platforms`: Import content for all platforms -Unimplemented `docker load` flags: `--quiet` - ### :whale: nerdctl save Save one or more images to a tar archive (streamed to STDOUT by default) @@ -816,6 +900,7 @@ Flags: - :whale: `--no-trunc`: Don't truncate output - :whale: `-q, --quiet`: Only display snapshots IDs - :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}` +- :whale: `-H, --human`: Print sizes and dates in human readable format (default true) ### :whale: nerdctl image prune @@ -826,10 +911,11 @@ 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=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 -Unimplemented `docker image prune` flags: `--filter` - ### :nerd_face: nerdctl image convert Convert an image format. @@ -847,6 +933,8 @@ Flags: - `--estargz-min-chunk-size=` : The minimal number of bytes of data must be written in one gzip stream (requires stargz-snapshotter >= v0.13.0). Useful for creating a smaller eStargz image (refer to [`./stargz.md`](./stargz.md) for details). - `--estargz-external-toc` : 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) Useful for creating a smaller eStargz image (refer to [`./stargz.md`](./stargz.md) for details). :warning: This flag is experimental and subject to change. - `--estargz-keep-diff-id`: Convert to esgz without changing diffID (cannot be used in conjunction with '--estargz-record-in'. must be specified with '--estargz-external-toc') +- `--zstd` : Use zstd compression instead of gzip. Should be used in conjunction with '--oci' +- `--zstd-compression-level=` : zstd compression level (default: 3) - `--zstdchunked` : Use zstd compression instead of gzip (a.k.a zstd:chunked). Should be used in conjunction with '--oci' - `--zstdchunked-record-in=` : read `ctr-remote optimize --record-out=` record file. :warning: This flag is experimental and subject to change. - `--zstdchunked-compression-level=`: zstd:chunked compression level (default: 3) @@ -959,8 +1047,9 @@ Flags: - :whale: `--gateway`: Gateway for the master subnet - :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. -Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--internal`, `--ipv6`, `--scope` +Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--internal`, `--scope` ### :whale: nerdctl network ls @@ -977,7 +1066,7 @@ Flags: - :nerd_face: `--format=wide`: Alias of `--format=table` - :nerd_face: `--format=json`: Alias of `--format='{{json .}}'` -Unimplemented `docker network ls` flags: `--filter`, `--no-trunc` +Unimplemented `docker network ls` flags: `--no-trunc` ### :whale: nerdctl network inspect @@ -1127,7 +1216,7 @@ Flags: ### :nerd_face: :blue_square: nerdctl namespace update -Udapte labels for a namespace. +Update labels for a namespace. Usage: `nerdctl namespace update NAMESPACE` @@ -1179,6 +1268,10 @@ Usage: `nerdctl builder prune` Flags: - :nerd_face: `--buildkit-host=`: BuildKit address +- :whale: `--all`: Remove all unused build cache, not just dangling ones +- :whale: `--force`: Do not prompt for confirmation + +Unimplemented `docker builder prune` flags: `--filter`, `--keep-storage` ### :nerd_face: nerdctl builder debug @@ -1188,6 +1281,8 @@ This is an [experimental](./experimental.md) feature. :warning: This command currently doesn't use the host's `buildkitd` daemon but uses the patched version of BuildKit provided by buildg. This should be fixed in the future. +:warning: This command is currently incompatible with `docker buildx debug`. + Usage: `nerdctl builder debug PATH` Flags: @@ -1197,8 +1292,6 @@ Flags: - :nerd_face: `--target`: Set the target build stage to build - :nerd_face: `--build-arg`: Set build-time variables -Unimplemented `docker builder prune` flags: `--all`, `--filter`, `--force`, `--keep-storage` - ## System ### :whale: nerdctl events @@ -1212,8 +1305,10 @@ Usage: `nerdctl events [OPTIONS]` Flags: - :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}` +- :whale: `-f, --filter`: Filter containers based on given conditions + - :whale: `--filter event=`: Event's status. Start is the only supported status. -Unimplemented `docker events` flags: `--filter`, `--since`, `--until` +Unimplemented `docker events` flags: `--since`, `--until` ### :whale: nerdctl info @@ -1317,6 +1412,7 @@ Flags: - :whale: `-p, --project-name`: Specify an alternate project name - :nerd_face: `--ipfs-address`: Multiaddr of IPFS API (default uses `$IPFS_PATH` env variable if defined or local directory `~/.ipfs`) - :whale: `--profile: Specify a profile to enable +- :whale: `--env-file` : Specify an alternate environment file ### :whale: nerdctl compose up @@ -1326,7 +1422,8 @@ Usage: `nerdctl compose up [OPTIONS] [SERVICE...]` Flags: -- :whale: `-d, --detach`: Detached mode: Run containers in the background +- :whale: `--abort-on-container-exit`: Stops all containers if any container was stopped. Incompatible with `-d`. +- :whale: `-d, --detach`: Detached mode: Run containers in the background. Incompatible with `--abort-on-container-exit`. - :whale: `--no-build`: Don't build an image, even if it's missing. - :whale: `--no-color`: Produce monochrome output - :whale: `--no-log-prefix`: Don't print prefix in logs @@ -1335,15 +1432,18 @@ Flags: - :whale: `--quiet-pull`: Pull without printing progress information - :whale: `--scale`: Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. - :whale: `--remove-orphans`: Remove containers for services not defined in the Compose file +- :whale: `--force-recreate`: force Compose to stop and recreate all containers +- :whale: `--no-recreate`: force Compose to reuse existing containers +- :whale: `--pull`: Pull image before running ("always"|"missing"|"never") -Unimplemented `docker-compose up` (V1) flags: `--no-deps`, `--force-recreate`, `--always-recreate-deps`, `--no-recreate`, +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` Unimplemented `docker compose up` (V2) flags: `--environment` ### :whale: nerdctl compose logs -Create and start containers +Show logs of running containers Usage: `nerdctl compose logs [OPTIONS] [SERVICE...]` @@ -1399,11 +1499,10 @@ Flags: - :whale: `-i, --interactive`: Keep STDIN open even if not attached (default true) - :whale: `--privileged`: Give extended privileges to the command - :whale: `-t, --tty`: Allocate a pseudo-TTY +- :whale: `-T, --no-TTY`: Disable pseudo-TTY allocation. By default nerdctl compose exec allocates a TTY. - :whale: `-u, --user`: Username or UID (format: `[:]`) - :whale: `-w, --workdir`: Working directory inside the container -Unimplemented `docker-compose exec` (V2) flags: `-T, --no-TTY` - ### :whale: nerdctl compose down Remove containers and associated resources @@ -1460,9 +1559,17 @@ List containers of services Usage: `nerdctl compose ps [OPTIONS] [SERVICE...]` -Unimplemented `docker-compose ps` (V1) flags: `--quiet`, `--services`, `--filter`, `--all` - -Unimplemented `docker compose ps` (V2) flags: `--status` +- :whale: `-a, --all`: Show all containers (default shows just running) +- :whale: `-q, --quiet`: Only display container IDs +- :whale: `--format`: Format the output + - :whale: `--format=table` (default): Table + - :whale: `--format=json'`: JSON +- :whale: `-f, --filter`: Filter containers based on given conditions + - :whale: `--filter status=`: One of `created, running, paused, + restarting, exited, pausing, unknown`. Note that `removing, dead` are + not supported and will be ignored +- :whale: `--services`: Print the service names, one per line +- :whale: `--status`: Filter containers by status. Values: [paused | restarting | running | created | exited | pausing | unknown] ### :whale: nerdctl compose pull @@ -1513,6 +1620,23 @@ Unimplemented `docker-compose config` (V1) flags: `--resolve-image-digests`, `-- Unimplemented `docker compose config` (V2) flags: `--resolve-image-digests`, `--no-interpolate`, `--format`, `--output`, `--profiles` +### :whale: nerdctl compose cp + +Copy files/folders between a service container and the local filesystem + +Usage: +``` +nerdctl compose cp [OPTIONS] SERVICE:SRC_PATH DEST_PATH|- +nerdctl compose cp [OPTIONS] SRC_PATH|- SERVICE:DEST_PATH [flags] +``` + +Flags: +- :whale: `--dry-run`: Execute command in dry run mode +- :whale: `-L, --follow-link`: Always follow symbol link in SRC_PATH +- :whale: `--index int`: index of the container if service has multiple replicas + +Unimplemented `docker compose cp` flags: `--archive` + ### :whale: nerdctl compose kill Force stop service containers @@ -1618,7 +1742,6 @@ See [`./config.md`](./config.md). Container management: -- `docker attach` - `docker diff` - `docker checkpoint *` diff --git a/docs/compose.md b/docs/compose.md index ac8cf696383..a07c91a5207 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -29,7 +29,6 @@ which was derived from [Docker Compose file version 3 specification](https://doc - `services..deploy.placement` - `services..deploy.endpoint_mode` - `services..healthcheck` -- `services..profiles` - `services..stop_grace_period` - `services..stop_signal` - `configs..external` diff --git a/docs/config.md b/docs/config.md index 2aca5dbce2c..1f0b4cd15e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -45,6 +45,8 @@ experimental = true | `hosts_dir` | `--hosts-dir` | | `certs.d` directory | Since 0.16.0 | | `experimental` | `--experimental` | `NERDCTL_EXPERIMENTAL` | Enable [experimental features](experimental.md) | Since 0.22.3 | | `host_gateway_ip` | `--host-gateway-ip` | `NERDCTL_HOST_GATEWAY_IP` | IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host | Since 1.3.0 | +| `bridge_ip` | `--bridge-ip` | `NERDCTL_BRIDGE_IP` | IP address for the default nerdctl bridge network, e.g., 10.1.100.1/24 | Since 2.0.1 | +| `kube-hide-dupe` | `--kube-hide-dupe` | | Deduplicate images for Kubernetes with namespace k8s.io, no more redundant ones are displayed | Since 2.0.3 | The properties are parsed in the following precedence: 1. CLI flag diff --git a/docs/cvmfs.md b/docs/cvmfs.md new file mode 100644 index 00000000000..7e3c812a62a --- /dev/null +++ b/docs/cvmfs.md @@ -0,0 +1,89 @@ +# Lazy-pulling using CernVM-FS Snapshotter + +CernVM-FS Snapshotter is a containerd snapshotter plugin. It is a specialized component responsible for assembling +all the layers of container images into a stacked file system that containerd can use. The snapshotter takes as input the list +of required layers and outputs a directory containing the final file system. It is also responsible to clean up the output +directory when containers using it are stopped. + +See the official [documentation](https://cvmfs.readthedocs.io/en/latest/cpt-containers.html#how-to-use-the-cernvm-fs-snapshotter) to learn further information. + +## Prerequisites + +- Install containerd remote snapshotter plugin (`cvmfs-snapshotter`) from [here](https://github.com/cvmfs/cvmfs/tree/devel/snapshotter). + +- Add the following to `/etc/containerd/config.toml`: +```toml +# Ask containerd to use this particular snapshotter +[plugins."io.containerd.grpc.v1.cri".containerd] + snapshotter = "cvmfs-snapshotter" + disable_snapshot_annotations = false + +# Set the communication endpoint between containerd and the snapshotter +[proxy_plugins] + [proxy_plugins.cvmfs] + type = "snapshot" + address = "/run/containerd-cvmfs-grpc/containerd-cvmfs-grpc.sock" +``` +- The default CernVM-FS repository hosting the flat root filesystems of the container images is `unpacked.cern.ch`. + The container images are unpacked into the CernVM-FS repository by the [DUCC](https://cvmfs.readthedocs.io/en/latest/cpt-ducc.html) + (Daemon that Unpacks Container Images into CernVM-FS) tool. + You can change the repository adding the following line to `/etc/containerd-cvmfs-grpc/config.toml`: +```toml +repository = "myrepo.mydomain" +``` +- Launch `containerd` and `cvmfs-snapshotter`: +```console +$ systemctl start containerd cvmfs-snapshotter +``` + +## Enable CernVM-FS Snapshotter for `nerdctl run` and `nerdctl pull` + +| :zap: Requirement | nerdctl >= 1.6.3 | +| ----------------- | ---------------- | + +- Run `nerdctl` with `--snapshotter cvmfs-snapshotter` as in the example below: +```console +$ nerdctl run -it --rm --snapshotter cvmfs-snapshotter clelange/cms-higgs-4l-full:latest +``` + +- You can also only pull the image with CernVM-FS Snapshotter without running the container: +```console +$ nerdctl pull --snapshotter cvmfs-snapshotter clelange/cms-higgs-4l-full:latest +``` + +The speedup for pulling this 9 GB (4.3 GB compressed) image is shown below: +- #### with the snapshotter: +```console +$ nerdctl --snapshotter cvmfs-snapshotter pull clelange/cms-higgs-4l-full:latest +docker.io/clelange/cms-higgs-4l-full:latest: resolved |++++++++++++++++++++++++++++++++++++++| +manifest-sha256:b8acbe80629dd28d213c03cf1ffd3d46d39e573f54215a281fabce7494b3d546: done |++++++++++++++++++++++++++++++++++++++| +config-sha256:89ef54b6c4fbbedeeeb29b1df2b9916b6d157c87cf1878ea882bff86a3093b5c: done |++++++++++++++++++++++++++++++++++++++| +elapsed: 4.7 s total: 19.8 K (4.2 KiB/s) + +$ nerdctl images +REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE +clelange/cms-higgs-4l-full latest b8acbe80629d 20 seconds ago linux/amd64 0.0 B 4.3 GiB +``` +- #### without the snapshotter: +```console +$ nerdctl pull clelange/cms-higgs-4l-full:latest +docker.io/clelange/cms-higgs-4l-full:latest: resolved |++++++++++++++++++++++++++++++++++++++| +manifest-sha256:b8acbe80629dd28d213c03cf1ffd3d46d39e573f54215a281fabce7494b3d546: exists |++++++++++++++++++++++++++++++++++++++| +config-sha256:89ef54b6c4fbbedeeeb29b1df2b9916b6d157c87cf1878ea882bff86a3093b5c: exists |++++++++++++++++++++++++++++++++++++++| +layer-sha256:e8114d4b0d10b33aaaa4fbc3c6da22bbbcf6f0ef0291170837e7c8092b73840a: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:a3eda0944a81e87c7a44b117b1c2e707bc8d18e9b7b478e21698c11ce3e8b819: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:8f3160776e8e8736ea9e3f6c870d14cd104143824bbcabe78697315daca0b9ad: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:22a5c05baa9db0aa7bba56ffdb2dd21246b9cf3ce938fc6d7bf20e92a067060e: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:bfcf9d498f92b72426c9d5b73663504d87249d6783c6b58d71fbafc275349ab9: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:0563e1549926b9c8beac62407bc6a420fa35bcf6f9844e5d8beeb9165325a872: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:6fff5fd7fb4eeb79a1399d9508614a84191d05e53f094832062d689245599640: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:25c39bfa66e1157415236703abc512d06cc1db31bd00fe8c3030c6d6d249dc4e: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:3cc0a0eb55eb3fb7ef0760c6bf1e567dfc56933ba5f11b5415f89228af751b72: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:a8850244786303e508b94bb31c8569310765e678c9c73bf1199310729209b803: done |++++++++++++++++++++++++++++++++++++++| +layer-sha256:32cdf5fc12485ac061347eb8b5c3b4a28505ce8564a7f3f83ac4241f03911176: done |++++++++++++++++++++++++++++++++++++++| +elapsed: 181.8s total: 4.3 Gi (24.2 MiB/s) + +$ nerdctl images +REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE +clelange/cms-higgs-4l-full latest b8acbe80629d 4 minutes ago linux/amd64 9.0 GiB 4.3 GiB +``` diff --git a/docs/dev/store.md b/docs/dev/store.md new file mode 100644 index 00000000000..c0954fb0063 --- /dev/null +++ b/docs/dev/store.md @@ -0,0 +1,163 @@ +# About `pkg/store` + +## TL;DR + +You _may_ want to read this if you are developing something in nerdctl that would involve storing persistent information. + +If there is a "store" already in the codebase (eg: volumestore, namestore, etc) that does provide the methods that you need, +you are fine and should just stick to that. + +On the other hand, if you are curious, or if what you want to write is "new", then _you should_ have a look at this document: +it does provide extended information about how we manage persistent data storage, especially with regard to concurrency +and atomicity. + +## Motivation + +The core of nerdctl aims at keeping its dependencies as lightweight as possible. +For that reason, nerdctl does not use a database to store persistent information, but instead uses the filesystem, +under a variety of directories. + +That "information" is typically volumes metadata, containers lifecycle info, the "name store" (which does ensure no two +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 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 +guarantees that concurrent operations can be performed safely, and that writes are "atomic", ensuring we never brick +user installs. + +For details about how, and what is done, read-on. + +## The problem with writing a file + +A write may very well be interrupted. + +While reading the resulting mangled file will typically break `json.Unmarshall` for example, and while we should still +handle such cases gracefully and provide meaningful information to the user about which file is damaged (which could be due +to the user manually modifying them), using "atomic" writes will (almost always (*)) prevent this from happening +on our part. + +An "atomic" write is usually performed by first writing data to a temporary file, and then, only if the write operation +succeeded, move that temporary file to its final destination. + +The `rename` syscall (see https://man7.org/linux/man-pages/man2/rename.2.html) is indeed "atomic" +(eg: it fully succeeds, or fails), providing said guarantees that you end-up with a complete file that has the entirety +of what was meant to be written. + +This is an "almost always", as an _operating system crash_ MAY break that promise (this is highly dependent on specifics +that are out of scope here, and that nerdctl has no control over). +Though, crashing operating systems is (hopefully) a sufficiently rare event that we can consider we "always" have atomic writes. + +There is one caveat with "rename-based atomic writes" though: if you mount the file itself inside a container, +an atomic write will not work as you expect, as the inode will (obviously) change when you modify the file, +and these changes will not be propagated inside the container. + +This caveat is the reason why `hostsstore` does NOT use an atomic write to update the `hosts` file, but a traditional write. + +## Concurrency between go routines + +This is a (simple) well-known problem. Just use a mutex to prevent concurrent modifications of the same object. + +Note that this is not much of a problem right now in nerdctl itself - but it might be in third-party applications using +our codebase. + +This is just generally good hygiene when building concurrency-safe packages. + +## Concurrency between distinct binary invocations + +This is much more of a problem. + +There are many good reasons and real-life scenarios where concurrent binary execution may happen. +A third-party deployment tool (similar to terraform for eg), that will batch a bunch of operations to be performed +to achieve a desired infrastructure state, and call many `nerdctl` invocations in parallel to achieve that. +This is also common-place in testing (subpackages). +And of course, a third-party tool that would be long-running and allow parallel execution, leveraging nerdctl codebase +as a library, may certainly produce these circumstances. + +The known answer to that problem is to use a filesystem lock (or flock). + +Concretely, the first process will "lock" a directory. All other processes trying to do the same will then be put +in a queue and wait for the prior lock to be released before they can "lock" themselves, in turn. + +Filesystem locking comes with its own set of challenges: +- implementation is somewhat low-level (the golang core library keeps their implementation internal, and you have to +reimplement your own with platform-dependent APIs and syscalls) +- it is tricky to get right - there are reasons why golang core does not make it public +- locking "scope" should be done carefully: having ONE global lock for everything will definitely hurt performance badly, +as you will basically make everything "sequential", effectively destroying some of the benefits of parallelizing code +in the first place... + +## Lock design... + +While it is tempting to just provide self-locking, individual methods as an API (`Get`, `Set`), this is not the right +answer. + +Imagine a context where consuming code would first like to check if something exists, then later on create it if it does not: +```golang +if !store.Exists("something") { + // do things + // ... + // Now, create + store.Set([]byte("data"), "something") +} +``` + +You do have two methods (`Get` and `Set`) that _may individually_ guarantee they are the sole user of that resource, +but a concurrent change _in between_ these two calls may very well (and _will_) happen and change the state of the world. + +Effectively, in that case, `Set` might overwrite changes made by another go routine or concurrent execution, possibly +wrecking havoc in another process. + +_When_ to lock, and _for how long_, is a decision that only the embedding code can make. + +A good example is container creation. +It may require the creation of several different volumes. +In that case, you want to lock at the start of the container creation process, and only release the lock when you are fully +done creating the container - not just when done creating a volume (nor even when done creating all volumes). + +## ... while safeguarding the developer + +nerdctl still provides some safeguards for the developer. + +Any store method that DOES require locking will fail loudly if it does not detect a lock. + +This is obviously not bullet-proof. +For example, the lock may belong to another goroutine instead of the one we are in (and we cannot detect that). +But this is still better than nothing, and will help developers making sure they **do** lock. + +## Using the `store` api to implement your own storage + +While - as mentioned above - the store does _not_ lock on its own, specific "stores implementations" may, and should, +provide higher-level methods that best fit their data-model usage, and that **do** lock on their own. + +For example, the namestore (which is the simplest store), does provide three simple methods: +- Acquire +- Release +- Rename + +Users of the `namestore` do not have to bother with locking. These methods are safe to use concurrently. + +This is a good example of how to leverage core store primitives to implement a developer friendly, safe storage for +"something" (in that case "names"). + +Finaly note an important point - mentioned above: locking should be done to the smallest possible "segment" of sub-directories. +Specifically, any store should lock only - at most - resources under the _namespace_ being manipulated. + +For example, a container lifecycle storage should not lock out any other container, but only its own private directory. + +## Scope, ambitions and future + +`pkg/store` has no ambition whatsoever to be a generic solution, usable outside nerdctl. + +It is solely designed to fit nerdctl needs, and if it was to be made usable standalone, would probably have to be modified +extensively, which is clearly out of scope here. + +Furthermore, there are already much more advanced generic solutions out there that you should use instead for outside-of-nerdctl projects. + +As for future, one nice thing we should consider is to implement read-only locks in addition to the exclusive, write-locks +we currently use. +The net benefit would be a performance boost in certain contexts (massively parallel, mostly read environments). \ No newline at end of file diff --git a/docs/dir.md b/docs/dir.md index f0084f0b23d..4843eadb6bc 100644 --- a/docs/dir.md +++ b/docs/dir.md @@ -34,6 +34,7 @@ Files: - `log-config.json`: used for storing the `--log-opts` map of `nerdctl run` - `-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 ### `//names/` e.g. `/var/lib/nerdctl/1935db59/names/default` @@ -64,5 +65,9 @@ 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. +Sub-folders inside are only available to the namespace bearing the same name, +and its networks definitions are private. + Files: - `nerdctl-.conflist`: CNI conf list created by nerdctl diff --git a/docs/faq.md b/docs/faq.md index 7280c503dba..c2313f9ce16 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -32,6 +32,7 @@ - [Containers do not automatically start after rebooting the host](#containers-do-not-automatically-start-after-rebooting-the-host) - [Error `failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: unable to apply cgroup configuration: unable to start unit ... {Name:Slice Value:"user.slice"} {Name:Delegate Value:true} ... Permission denied: unknown`](#error-failed-to-create-shim-task-oci-runtime-create-failed-runc-create-failed-unable-to-start-container-process-unable-to-apply-cgroup-configuration-unable-to-start-unit--nameslice-valueuserslice-namedelegate-valuetrue--permission-denied-unknown) - [How to uninstall ? / Can't remove `~/.local/share/containerd`](#how-to-uninstall---cant-remove-localsharecontainerd) + - [How to clean a dangling cache of buildkit?](#how-to-clean-a-dangling-cache-of-buildkit) @@ -355,3 +356,15 @@ Run the following commands: containerd-rootless-setuptool.sh uninstall rootlesskit rm -rf ~/.local/share/containerd ~/.local/share/nerdctl ~/.config/containerd ``` + +### How to clean a dangling cache of buildkit? + +`buildkit` cache directory is located at `$HOME/.local/share/buildkit/` +in rootless mode, which has same folder structure `/var/lib/buildkit/` in +root mode. + +You can clear the cache objects by running the following command: +``` +nerdctl builder prune +``` +The command produce a progress message of id and size of removed objects. diff --git a/docs/freebsd.md b/docs/freebsd.md index aa7b404da9e..4ad17aedba3 100644 --- a/docs/freebsd.md +++ b/docs/freebsd.md @@ -14,10 +14,16 @@ instructions in the respective repositories. ## Usage -You can use the `knast/freebsd` image to run a standard FreeBSD 13 jail: +You can use the `dougrabson/freebsd13.2-small` image to run a FreeBSD 13 jail: ```sh -nerdctl run --net none -it knast/freebsd:13-STABLE +nerdctl run --net none -it dougrabson/freebsd13.2-small +``` + +Alternatively use `--platform` parameter to run linux containers + +```sh +nerdctl run --platform linux --net none -it amazonlinux:2 ``` @@ -25,10 +31,3 @@ nerdctl run --net none -it knast/freebsd:13-STABLE - :warning: CNI & CNI plugins are not yet ported to FreeBSD. The only supported network type is `none` -- :warning: buildkit is not yet ported to FreeBSD. - - [ ] https://github.com/tonistiigi/fsutil/pull/109 - buildkit dependency - - [ ] https://github.com/moby/moby/pull/42866 - buildkit dependency -- :warning: Linuxulator containers support is - WIP. https://github.com/containerd/nerdctl/issues/280 https://github.com/containerd/containerd/pull/5480 - -- :bug: `nerdctl compose` commands currently don't work. https://github.com/containerd/containerd/pull/5991 diff --git a/docs/gpu.md b/docs/gpu.md index cd200351a3c..009170c1a37 100644 --- a/docs/gpu.md +++ b/docs/gpu.md @@ -20,7 +20,7 @@ You can specify number of GPUs to use via `--gpus` option. The following example exposes all available GPUs. ``` -nerdctl run -it --rm --gpus all nvidia/cuda:9.0-base nvidia-smi +nerdctl run -it --rm --gpus all nvidia/cuda:12.3.1-base-ubuntu20.04 nvidia-smi ``` You can also pass detailed configuration to `--gpus` option as a list of key-value pairs. The following options are provided. @@ -32,7 +32,7 @@ You can also pass detailed configuration to `--gpus` option as a list of key-val 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:9.0-base nvidia-smi +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 ``` ## Fields for `nerdctl compose` @@ -53,7 +53,7 @@ The following exposes all available GPUs to the container. version: "3.8" services: demo: - image: nvidia/cuda:9.0-base + image: nvidia/cuda:12.3.1-base-ubuntu20.04 command: nvidia-smi deploy: resources: @@ -62,3 +62,24 @@ services: - capabilities: ["utility"] count: all ``` + +## Trouble Shooting + +### `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)`. + +So, the `nvidia-container-cli` needs to be added to the PATH environment variable. + +You can do this by adding the following line to your $HOME/.profile or /etc/profile (for a system-wide installation): +``` +export PATH=$PATH:/usr/local/nvidia/toolkit +``` + +The shared libraries also need to be added to the system. +``` +echo "/run/nvidia/driver/usr/lib/x86_64-linux-gnu" > /etc/ld.so.conf.d/nvidia.conf +ldconfig +``` + +And then, the `nerdctl run --gpus` can run successfully. diff --git a/docs/images/nerdctl-white.svg b/docs/images/nerdctl-white.svg new file mode 100644 index 00000000000..8edeab006a5 --- /dev/null +++ b/docs/images/nerdctl-white.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/nerdctl.svg b/docs/images/nerdctl.svg new file mode 100644 index 00000000000..e4d9789beed --- /dev/null +++ b/docs/images/nerdctl.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/rootlessKit-network-design.png b/docs/images/rootlessKit-network-design.png new file mode 100644 index 00000000000..5a1696b7095 Binary files /dev/null and b/docs/images/rootlessKit-network-design.png differ diff --git a/docs/ipfs.md b/docs/ipfs.md index 29a98524a1b..488a1488f90 100644 --- a/docs/ipfs.md +++ b/docs/ipfs.md @@ -121,7 +121,7 @@ localhost:5050/ipfs/ Here, `CID` is the IPFS CID of the image. -:information_source: In the futural version of nerdctl and BuildKit, `ipfs://` prefix should be supported in Dockerfile. +:information_source: In the future version of nerdctl and BuildKit, `ipfs://` prefix should be supported in Dockerfile. Using this image reference, you can build an image on IPFS. @@ -179,7 +179,7 @@ By default, nerdctl exposes the registry at `localhost:5050` (configurable via f Optionally you can create systemd unit file of `nerdctl ipfs registry serve`. An example systemd unit file for `nerdctl ipfs registry serve` can be the following. -`nerdctl ipfs registry serve` is aware of environemnt variables for configuring the behaviour (e.g. listening port) so you can use `EnvironmentFile` for configuring it. +`nerdctl ipfs registry serve` is aware of environment variables for configuring the behaviour (e.g. listening port) so you can use `EnvironmentFile` for configuring it. ``` [Unit] diff --git a/docs/multi-platform.md b/docs/multi-platform.md index 22113eeccf7..e2213c36482 100644 --- a/docs/multi-platform.md +++ b/docs/multi-platform.md @@ -11,7 +11,7 @@ e.g., ARM on Intel, and vice versa. ```console $ sudo systemctl start containerd -$ sudo nerdctl run --privileged --rm tonistiigi/binfmt --install all +$ 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 diff --git a/docs/notation.md b/docs/notation.md index 34cd7682365..8e155c84e93 100644 --- a/docs/notation.md +++ b/docs/notation.md @@ -10,7 +10,7 @@ under the hood with make use of flags `--sign` while pushing the container image container image. * Ensure notation executable in your `$PATH`. -* You can install notation by following this page: https://notaryproject.dev/docs/installation/cli/ +* You can install notation by following this page: https://notaryproject.dev/docs/user-guides/installation/cli/ * Notation follows the RC of OCI spec v1.1.0. Follow the [instruction](https://notaryproject.dev/docs/quickstart/#create-an-oci-compatible-registry) to set up the local registry with the compliance for testing purpose. Prepare your environment: @@ -50,7 +50,7 @@ $ nerdctl push --sign=notation --notation-key-name test localhost:5000/my-test Verify the container image while pulling: -> REMINDER: Image won't be pulled if there are no matching signatures with the cert in the [trust policy](https://notaryproject.dev/docs/concepts/trust-store-trust-policy-specification/#trust-policy) in case you passed `--verify` flag. +> REMINDER: Image won't be pulled if there are no matching signatures with the cert in the [trust policy](https://github.com/notaryproject/specifications/blob/main/specs/trust-store-trust-policy.md#trust-policy) in case you passed `--verify` flag. ```shell # Create `trustpolicy.json` under $XDG_CONFIG_HOME/notation (XDG_CONFIG_HOME is ~/.config below) diff --git a/docs/nydus.md b/docs/nydus.md index 338f09f1639..1019827a548 100644 --- a/docs/nydus.md +++ b/docs/nydus.md @@ -30,7 +30,7 @@ For the list of pre-converted Nydus images, see https://github.com/orgs/dragonfl Nerdctl supports to convert an OCI image or docker format v2 image to Nydus image by using the `nerdctl image convert` command. -Before the conversion, you should have the `nydus-image` binary installed, which is contained in the ["nydus static package"](https://github.com/dragonflyoss/image-service/releases). You can run the command like `nerdctl image convert --nydus --oci --nydus-image ` to convert the `` to a Nydus image whose tag is ``. +Before the conversion, you should have the `nydus-image` binary installed, which is contained in the ["nydus static package"](https://github.com/dragonflyoss/image-service/releases). You can run the command like `nerdctl image convert --nydus --oci --nydus-builder-path ` to convert the `` to a Nydus image whose tag is ``. By now, the converted Nydus image cannot be run directly. It shoud be unpacked to nydus snapshotter before `nerdctl run`, which is a part of the processing flow of `nerdctl image pull`. So you need to push the converted image to a registry after the conversion and use `nerdctl --snapshotter nydus image pull` to unpack it to the nydus snapshotter before running the image. diff --git a/docs/registry.md b/docs/registry.md index 299809e26f2..b74e1ffa18b 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -49,6 +49,7 @@ See https://github.com/containerd/nerdctl/issues/86 for the discussion about wor + - [Amazon Elastic Container Registry (ECR)](#amazon-elastic-container-registry-ecr) - [Logging in](#logging-in) - [Creating a repo](#creating-a-repo) @@ -74,7 +75,7 @@ See https://github.com/containerd/nerdctl/issues/86 for the discussion about wor - [Logging in](#logging-in-5) - [Creating a repo](#creating-a-repo-5) - [Pushing an image](#pushing-an-image-5) -- [Google Container Registry (GCR)](#google-container-registry-gcr) +- [Google Container Registry (GCR) [DEPRECATED]](#google-container-registry-gcr-deprecated) - [Logging in](#logging-in-6) - [Creating a repo](#creating-a-repo-6) - [Pushing an image](#pushing-an-image-6) @@ -280,7 +281,7 @@ Create a [GCP Service Account](https://cloud.google.com/iam/docs/creating-managi Then run the following command: ```console -$ cat | docker login -u _json_key --password-stdin https://-docker.pkg.dev +$ cat | nerdctl login -u _json_key --password-stdin https://-docker.pkg.dev WARNING! Your password will be stored unencrypted in /home//.docker/config.json. Configure a credential helper to remove this warning. See https://docs.docker.com/engine/reference/commandline/login/#credentials-store @@ -288,7 +289,7 @@ https://docs.docker.com/engine/reference/commandline/login/#credentials-store Login Succeeded ``` -See also https://cloud.google.com/artifact-registry/docs/docker/authentication#json-key +See also https://cloud.google.com/artifact-registry/docs/docker/authentication
@@ -336,7 +337,7 @@ $ nerdctl push -docker.pkg.dev///hello-world The pushed image appears in the repository you manually created in the previous step. -## Google Container Registry (GCR) +## Google Container Registry (GCR) [DEPRECATED] See also https://cloud.google.com/container-registry/docs/advanced-authentication ### Logging in @@ -347,7 +348,7 @@ Create a [GCP Service Account](https://cloud.google.com/iam/docs/creating-managi Then run the following command: ```console -$ cat | docker login -u _json_key --password-stdin https://asia.gcr.io +$ cat | nerdctl login -u _json_key --password-stdin https://asia.gcr.io WARNING! Your password will be stored unencrypted in /home//.docker/config.json. Configure a credential helper to remove this warning. See https://docs.docker.com/engine/reference/commandline/login/#credentials-store diff --git a/docs/rootless.md b/docs/rootless.md index 4c9cfa5d4b1..1000bd50865 100644 --- a/docs/rootless.md +++ b/docs/rootless.md @@ -25,6 +25,10 @@ The usage of `containerd-rootless-setuptool.sh` is almost same as [`dockerd-root Resource limitation flags such as `nerdctl run --memory` require systemd and cgroup v2: https://rootlesscontaine.rs/getting-started/common/cgroup2/ +#### AppArmor Profile for Ubuntu 24.04+ + +Configuring AppArmor is needed only on Ubuntu 24.04+, with RootlessKit installed under a non-standard path: https://rootlesscontaine.rs/getting-started/common/apparmor/ + ## Client (nerdctl) Just execute `nerdctl`. No need to specify the socket address manually. @@ -109,7 +113,7 @@ See https://github.com/containerd/stargz-snapshotter/blob/main/docs/pre-converte |-------------------|-----------------| -[bypass4netns(https://github.com/rootless-containers/bypass4netns)](https://github.com/rootless-containers/bypass4netns) is an accelerator for rootless networking. +[bypass4netns](https://github.com/rootless-containers/bypass4netns) is an accelerator for rootless networking. This improves **outgoing or incoming (with --publish option) networking performance.** @@ -121,16 +125,69 @@ The performance benchmark with iperf3 on Ubuntu 21.10 on Hyper-V VM is shown bel This benchmark can be reproduced with [https://github.com/rootless-containers/bypass4netns/blob/f009d96139e9e38ce69a2ea8a9a746349bad273c/Vagrantfile](https://github.com/rootless-containers/bypass4netns/blob/f009d96139e9e38ce69a2ea8a9a746349bad273c/Vagrantfile) -Acceleration with bypass4netns is available with `--label nerdctl/bypass4netns=true`. You also need to have `bypass4netnsd` (bypass4netns daemon) to be running. +Acceleration with bypass4netns is available with: +- `--annotation nerdctl/bypass4netns=true` (for nerdctl v2.0 and later) +- `--label nerdctl/bypass4netns=true` (deprecated form, used in nerdctl prior to v2.0). + +You also need to have `bypass4netnsd` (bypass4netns daemon) to be running. Example ```console $ containerd-rootless-setuptool.sh install-bypass4netnsd -$ nerdctl run -it --rm -p 8080:80 --label nerdctl/bypass4netns=true alpine +$ nerdctl run -it --rm -p 8080:80 --annotation nerdctl/bypass4netns=true alpine ``` More detail is available at [https://github.com/rootless-containers/bypass4netns/blob/master/README.md](https://github.com/rootless-containers/bypass4netns/blob/master/README.md) +## Configuring RootlessKit + +Rootless containerd recognizes the following environment variables to configure the behavior of [RootlessKit](https://github.com/rootless-containers/rootlesskit): + +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_STATE_DIR=DIR`: the rootlesskit state dir. Defaults to `$XDG_RUNTIME_DIR/containerd-rootless`. +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_NET=(slirp4netns|vpnkit|lxc-user-nic)`: the rootlesskit network driver. Defaults to "slirp4netns" if slirp4netns (>= v0.4.0) is installed. Otherwise defaults to "vpnkit". +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_MTU=NUM`: the MTU value for the rootlesskit network driver. Defaults to 65520 for slirp4netns, 1500 for other drivers. +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=(builtin|slirp4netns)`: the rootlesskit port driver. Defaults to "builtin" (this driver does not propagate the container's source IP address and always uses 127.0.0.1. Please check [Port Drivers](https://github.com/rootless-containers/rootlesskit/blob/master/docs/port.md#port-drivers) for more details). +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX=(auto|true|false)`: whether to protect slirp4netns with a dedicated mount namespace. Defaults to "auto". +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP=(auto|true|false)`: whether to protect slirp4netns with seccomp. Defaults to "auto". +* `CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=(auto|true|false)`: whether to launch rootlesskit with the "detach-netns" mode. + Defaults to "auto", which is resolved to "true" if RootlessKit >= 2.0 is installed. + The "detached-netns" mode accelerates `nerdctl (pull|push|build)` and enables `nerdctl run --net=host`, + however, there is a relatively minor drawback with BuildKit prior to v0.13: + the host loopback IP address (127.0.0.1) and abstract sockets are exposed to Dockerfile's "RUN" instructions during `nerdctl build` (not `nerdctl run`). + The drawback is fixed in BuildKit v0.13. Upgrading from a prior version of BuildKit needs removing the old systemd unit: + `containerd-rootless-setuptool.sh uninstall-buildkit && rm -f ~/.config/buildkit/buildkitd.toml` + +To set these variables, create `~/.config/systemd/user/containerd.service.d/override.conf` as follows: +```ini +[Service] +Environment=CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS="false" +``` + +And then run the following commands: +```bash +systemctl --user daemon-reload +systemctl --user restart containerd +``` + ## Troubleshooting ### Hint to Fedora users - If SELinux is enabled on your host and your kernel is older than 5.13, you need to use [`fuse-overlayfs` instead of `overlayfs`](#fuse-overlayfs). + +## Rootlesskit Network Design + +In `detach-netns` mode: + +- Network namespace is detached and stored in `$ROOTLESSKIT_STATE_DIR/netns`. +- The child command executes within the host's network namespace, allowing actions like `pull` and `push` to happen in the host network namespace. +- For creating and configuring the container's network namespace, the child command switches temporarily to the relevant namespace located in `$ROOTLESSKIT_STATE_DIR/netns`. This ensures necessary network setup while maintaining isolation in the host namespace. + +![rootlessKit-network-design.png](images/rootlessKit-network-design.png) + +- Rootlesskit Parent NetNS and Child NetNS are already configured by the startup script [containerd-rootless.sh](https://github.com/containerd/nerdctl/blob/main/extras/rootless/containerd-rootless.sh) +- Rootlesskit Parent NetNS is the host network namespace +- step1: `nerdctl` calls `containerd` in the host network namespace. +- step2: `containerd` calls `runc` in the host network namespace. +- step3: `runc` creates container with dedicated namespaces (e.g network ns) in the Parent netns. +- step4: `runc` nsenter Rootlesskit Child NetNS before triggering nerdctl ocihook. +- step5: `nerdctl` ocihook module leverages CNI. +- step6: CNI configures container network namespace: create network interfaces `eth0` -> `veth0` -> `nerdctl0`. diff --git a/docs/soci.md b/docs/soci.md new file mode 100644 index 00000000000..67fbe92f584 --- /dev/null +++ b/docs/soci.md @@ -0,0 +1,47 @@ +# Lazy-pulling using SOCI Snapshotter + +SOCI Snapshotter is a containerd snapshotter plugin. It enables standard OCI images to be lazily loaded without requiring a build-time conversion step. "SOCI" is short for "Seekable OCI", and is pronounced "so-CHEE". + +See https://github.com/awslabs/soci-snapshotter to learn further information. + +## Prerequisites + +- Install containerd remote snapshotter plugin (`soci-snapshotter-grpc`) from https://github.com/awslabs/soci-snapshotter/blob/main/docs/getting-started.md + +- Add the following to `/etc/containerd/config.toml`: +```toml +[proxy_plugins] + [proxy_plugins.soci] + type = "snapshot" + address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock" +``` + +- Launch `containerd` and `soci-snapshotter-grpc` + +## Enable SOCI for `nerdctl run` and `nerdctl pull` + +| :zap: Requirement | nerdctl >= 1.5.0 | +| ----------------- | ---------------- | + +- Run `nerdctl` with `--snapshotter=soci` +```console +nerdctl run -it --rm --snapshotter=soci public.ecr.aws/soci-workshop-examples/ffmpeg:latest +``` + +- You can also only pull the image with SOCI without running the container. +```console +nerdctl pull --snapshotter=soci public.ecr.aws/soci-workshop-examples/ffmpeg:latest +``` + +For images that already have SOCI indices, see https://gallery.ecr.aws/soci-workshop-examples + +## Enable SOCI for `nerdctl push` + +| :zap: Requirement | nerdctl >= 1.6.0 | +| ----------------- | ---------------- | + +- Push the image with SOCI index. Adding `--snapshotter=soci` arg to `nerdctl pull`, `nerdctl` will create the SOCI index and push the index to same destination as the image. +```console +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. diff --git a/docs/stargz.md b/docs/stargz.md index b54efb4bac4..5a54fe17906 100644 --- a/docs/stargz.md +++ b/docs/stargz.md @@ -175,9 +175,9 @@ You can use zstd compression with lazy pulling support (a.k.a zstd:chunked) inst - [Faster](https://github.com/facebook/zstd/tree/v1.5.2#benchmarks) compression/decompression. - Cons - Old tools might not support. And unsupported by some tools yet. - - zstd support by OCI Image Specification is still under rc (2022/11). will be added to [v1.1.0](https://github.com/opencontainers/image-spec/commit/1a29e8675a64a5cdd2d93b6fa879a82d9a4d926a). - - zstd support unreleased [by Docker](https://github.com/moby/moby/pull/41759/commits/e187eb2bb5f0c3f899fe643e95d1af8c57e89a73) (will be added to v22.06). - - [containerd >= v1.5](https://github.com/containerd/containerd/releases/tag/v1.5.0) supports zstd. + - zstd supported by OCI Image Specification is still under rc (2022/11). will be added to [v1.1.0](https://github.com/opencontainers/image-spec/commit/1a29e8675a64a5cdd2d93b6fa879a82d9a4d926a). + - zstd supported by [docker >=v23.0.0](https://github.com/moby/moby/releases/tag/v23.0.0). + - zstd supported by [containerd >= v1.5](https://github.com/containerd/containerd/releases/tag/v1.5.0). - `min-chunk-size`, `external-toc` (described in Tips 1) are unsupported yet. ```console diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 00000000000..c5bddc99285 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,118 @@ +# Testing nerdctl + +This document covers basic usage of nerdctl testing tasks, and generic recommendations +and principles about writing tests. + +For more comprehensive information about nerdctl test tools, see [tools.md](tools.md). + +## Lint + +``` +go mod tidy +golangci-lint run ./... +``` + +This works on macOS as well - just pass along `GOOS=linux`. + +## Unit testing + +Run `go test -v ./pkg/...` + +## Integration testing + +### TL;DR + +Be sure to first `make && sudo make install` + +```bash +# Test all with nerdctl (rootless mode, if running go as a non-root user) +go test -p 1 ./cmd/nerdctl/... + +# Test all with nerdctl rootful +go test -p 1 -exec sudo ./cmd/nerdctl/... + +# Test all with docker +go test -p 1 ./cmd/nerdctl/... -args -test.target=docker + +# Test just the tests(s) which names match TestVolume.* +go test -p 1 ./cmd/nerdctl/... -run "TestVolume.*" +# Or alternatively, just test the subpackage +go test ./cmd/nerdctl/volume +``` + +### About parallelization + +By default, when `go test ./foo/...` finds subpackages, it does create _a separate test binary +per sub-package_, and execute them _in parallel_. +This effectively will make distinct tests in different subpackages to be executed in +parallel, regardless of whether they called `t.Parallel` or not. + +The `-p 1` flag does inhibit this behavior, and forces go to run each sub-package +sequentially. + +Note that this is different from the `--parallel` flag, which controls the amount of +parallelization that a single go test binary will use when faced with tests that do +explicitly allow it (with a call to `t.Parallel()`). + +### Or test in a container + +```bash +docker build -t test-integration --target test-integration . +docker run -t --rm --privileged test-integration +``` + +### Principles + +#### Tests should be parallelized (with best effort) + +##### General case + +It should be possible to parallelize all tests - as such, please make sure you: +- name all resources your test is manipulating after the test identifier (`testutil.Identifier(t)`) +to guarantee your test will not interact with other tests +- do NOT use `os.Setenv` - instead, add into `base.Env` +- use `t.Parallel()` at the beginning of your test (and subtests as well of course) +- in the very exceptional case where your test for some reason can NOT be parallelized, be sure to mark it explicitly as such +with a comment explaining why + +##### For "blanket" destructive operations + +If you are going to use blanket destructive operations (like `prune`), please: +- use a dedicated namespace: instead of calling `testutil.Base`, call `testutil.BaseWithNamespace` +and be sure that your namespace is named after the test id +- remove the namespace in your test `Cleanup` +- since docker does not support namespaces, be sure to: + - only enable `Parallel` if the target is NOT docker: ` if testutil.GetTarget() != testutil.Docker { t.Parallel() }` + - double check that what you do in the default namespace is safe + +#### Clean-up after (and before) yourself + +- do NOT use `defer`, use `t.Cleanup` +- do NOT test the result of commands doing the cleanup - it is fine if they fail, +and they are not test failure per-se - they are here to garbage collect +- you should call your cleanup routine BEFORE doing anything, in case there is any +leftovers from previous runs, typically: +``` +tearDown := func(){ + // Do some cleanup +} + +tearDown() +t.Cleanup(tearDown) +``` + +#### Test what you are testing, and not something else + +You should only test atomically. + +If you are testing `nerdctl volume create`, make sure that your test will not fail +because of changes in `nerdctl volume inspect`. + +That obviously means there are certain things you cannot test "yet". +Just put the right test in the right place with this simple rule of thumb: +if your test requires another nerdctl command to validate the result, then it does +not belong here. Instead, it should be a test for that other command. + +Of course, this is not perfect, and changes in `create` may now fail in `inspect` tests +while `create` could be faulty, but it does beat the alternative, because of this principle: +it is easier to walk *backwards* from a failure. \ No newline at end of file diff --git a/docs/testing/tools.md b/docs/testing/tools.md new file mode 100644 index 00000000000..2f53441b30c --- /dev/null +++ b/docs/testing/tools.md @@ -0,0 +1,437 @@ +# Nerdctl testing tools + +## Preamble + +The integration test suite in nerdctl is meant to apply to both nerdctl and docker, +and further support additional test properties to target specific contexts (ipv6, kube). + +Basic _usage_ is covered in the [testing docs](README.md). + +This here covers how to write tests, leveraging nerdctl `pkg/testutil/test` +which has been specifically developed to take care of repetitive tasks, +protect the developer against unintended side effects across tests, and generally +encourage clear testing structure with good debug-ability and a relatively simple API for +most cases. + +## Using `test.Case` + +Starting from scratch, the simplest, basic structure of a new test is: + +```go +package main + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMyThing(t *testing.T) { + // Declare your test + myTest := nerdtest.Setup() + // This is going to run `nerdctl info` (or `docker info`) + mytest.Command = test.Command("info") + // Verify the command exits with 0, and stdout contains the word `Kernel` + myTest.Expected = test.Expects(0, nil, test.Contains("Kernel")) + // Run it + myTest.Run(t) +} +``` + +## Expectations + +There are a handful of helpers for "expectations". + +You already saw two (`test.Expects` and `test.Contains`): + +First, `test.Expects(exitCode int, errors []error, outputCompare Comparator)`, which is +convenient to quickly describe what you expect overall. + +`exitCode` is obvious (note that passing -1 as an exit code will just +verify the commands does fail without comparing the code, and -2 will not verify the exit +code at all). + +`errors` is a slice of go `error`, that allows you to compare what is seen on stderr +with existing errors (for example: `errdefs.ErrNotFound`), or more generally +any string you want to match. + +`outputCompare` can be either your own comparison function, or +one of the comparison helper. + +Secondly, `test.Contains` - which is a `Comparator`. + +### Comparators + +Besides `test.Contains(string)`, there are a few more: +- `test.DoesNotContain(string)` +- `test.Equals(string)` +- `test.Match(*regexp.Regexp)` +- `test.All(comparators ...Comparator)`, which allows you to bundle together a bunch of other comparators + +The following example shows how to implement your own custom `Comparator` +(this is actually the `Equals` comparator). + +```go +package whatever + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func MyComparator(compare string) test.Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + assert.Assert(t, stdout == compare, info) + } +} +``` + +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 +that is being used in the command or at other stages of your test (Setup). + +For example, creating a container with a certain name, you might want to verify +that this name is then visible in the list of containers. + +To achieve that, you should write your own `Manager`, leveraging test `Data`. + +Here is an example, where we are using `data.Get("sometestdata")`. + +```go +package main + +import ( + "errors" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMyThing(t *testing.T) { + nerdtest.Setup() + + // Declare your test + myTest := &test.Case{ + Data: test.WithData("sometestdata", "blah"), + Command: test.Command("info"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{ + errors.New("foobla"), + errdefs.ErrNotFound, + }, + Output: func(stdout string, info string, t *testing.T) { + assert.Assert(t, stdout == data.Get("sometestdata"), info) + }, + } + }, + } + + myTest.Run(t) +} +``` + +## On `Data` + +`Data` is provided to allow storing mutable key-value information that pertain to the test. + +While it can be provided through `test.WithData(key string, value string)`, +inside the testcase definition, it can also be dynamically manipulated inside `Setup`, or `Command`. + +Note that `Data` additionally exposes the following functions: +- `Identifier(words ...string)` which returns a unique identifier associated with the _current_ test (or subtest) +- `TempDir()` which returns the private, temporary directory associated with the test + +... along with the `Get(key)` and `Set(key, value)` methods. + +Note that Data is copied down to subtests, which is convenient to pass "down" +information relevant to a bunch of subtests (eg: like a registry IP). + +## On Config + +`Config` is similar to `Data`, although it is meant specifically for predefined +keys that impact the base behavior of the binary you are testing. + +You can initiate your config using `test.WithConfig(key, value)`, and you can +manipulate it further using `helpers.Read` and`helpers.Write`. + +Currently, the following keys are defined: +- `DockerConfig` allowing to set custom content for the `$DOCKER_CONFIG/config.json` file +- `Namespace` (default to `nerdctl-test` if unspecified, but see "mode private") +- `NerdctlToml` to set custom content for the `$NERDCTL_TOML` file +- `HostsDir` to specify the value of the arg `--hosts-dir` +- `DataRoot` to specify the value of the arg `--data-root` +- `Debug` to enable debug (works for both nerdctl and docker) + +Note that config defined on the test case is copied over for subtests. + +## Commands + +For simple cases, `test.Command(args ...string)` is the way to go. + +It will execute the binary to test (nerdctl or docker), with the provided arguments, +and will by default get cwd inside the temporary directory associated with the test. + +### Environment + +You can attach custom environment variables for your test in the `Env` property of your +test. + +These will be automatically added to the environment for your command, and also +your setup and cleanup routines (see below). + +If you would like to override the environment specifically for a command, but not for +others (eg: in `Setup` or `Cleanup`), you can do so with custom commands (see below). + +Note that environment as defined statically in the test will be copied over for subtests. + +### Working directory + +By default, the working directory of the command will be set to the temporary directory +of the test. + +This behavior can be overridden using custom commands. + +### Custom Executor + +Custom `Executor`s allow you to manipulate test `Data`, override important aspects +of the command to execute (`Env`, `WorkingDir`), or otherwise give you full control +on what the command does. + +You just need to implement an `Executor`: + +```go +package main + +import ( + "errors" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMyThing(t *testing.T) { + nerdtest.Setup() + + // Declare your test + myTest := &test.Case{ + Data: test.WithData("sometestdata", "blah"), + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--name", data.Get("sometestdata")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{ + errors.New("foobla"), + errdefs.ErrNotFound, + }, + Output: func(stdout string, info string, t *testing.T) { + assert.Assert(t, stdout == data.Get("sometestdata"), info) + }, + } + }, + } + + myTest.Run(t) +} +``` + +Note that inside your `Executor` you do have access to the full palette of command options, +including: +- `Background(timeout time.Duration)` which allows you to background a command execution +- `WithWrapper(binary string, args ...string)` which allows you to "wrap" your command with another binary +- `WithStdin(io.Reader)` which allows you to pass a reader to the command stdin +- `WithCwd(string)` which allows you to specify the working directory (default to the test temp directory) +- `Clone()` which returns a copy of the command, with env, cwd, etc + +and also `WithBinary` and `WithArgs`. + +### On `helpers` + +Inside a custom `Executor`, `Manager`, or `Butler`, you have access to a collection of +`helpers` to simplify command execution: + +- `helpers.Ensure(args ...string)` will run a command and ensure it exits successfully +- `helpers.Fail(args ...string)` will run a command and ensure it fails +- `helpers.Anyhow(args ...string)` will run a command but does not care if it succeeds or fails +- `helpers.Capture(args ...string)` will run a command, ensure it is successful, and return the output +- `helpers.Command(args ...string)` will return a command that can then be tested against expectations +- `helpers.Custom(binary string, args ...string)` will do the same for any arbitrary command (not limited to nerdctl) +- `helpers.T()` which returns the appropriate `*testing.T` for your context + +## Setup and Cleanup + +Tests routinely require a set of actions to be performed _before_ you can run the +command you want to test. +A setup routine will get executed before your `Command`, and have access to and can +manipulate your test `Data` and `Config`. + +Conversely, you very likely want to clean things up once your test is done. +While temporary directories are cleaned for you with no action needed on your part, +the app you are testing might have stateful data you may want to remove. +Note that a `Cleanup` routine will get executed twice - after your `Command` has run +its course evidently - but also, pre-emptively, before your `Setup`, so that possible leftovers from +previous runs are taken care of. + +```go +package main + +import ( + "errors" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func TestMyThing(t *testing.T) { + nerdtest.Setup() + + // Declare your test + myTest := &test.Case{ + Data: test.WithData("sometestdata", "blah"), + Setup: func(data *test.Data, helpers test.Helpers){ + helpers.Ensure("volume", "create", "foo") + helpers.Ensure("volume", "create", "bar") + }, + Cleanup: func(data *test.Data, helpers test.Helpers){ + helpers.Anyhow("volume", "rm", "foo") + helpers.Anyhow("volume", "rm", "bar") + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--name", data.Identifier()+data.Get("sometestdata")) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 1, + Errors: []error{ + errors.New("foobla"), + errdefs.ErrNotFound, + }, + Output: func(stdout string, info string, t *testing.T) { + assert.Assert(t, stdout == data.Get("sometestdata"), info) + }, + } + }, + } + + myTest.Run(t) +} +``` + +## Subtests + +Subtests are just regular tests, attached to the `SubTests` slice of a test. + +Note that a subtest will inherit its parent `Data`, `Config` and `Env`, in the state they are at +after the parent test has run its `Setup` and `Command` routines (but before `Cleanup`). +This does _not_ apply to `Identifier()` and `TempDir()`, which are unique to the subtest. + +Also note that a test does not have to have a `Command`. +This is a convenient pattern if you just need a common `Setup` for a bunch of subtests. + +## Parallelism + +All tests (and subtests) are assumed to be parallelizable. + +You can force a specific `test.Case` to not be run in parallel though, +by setting its `NoParallel` property to `true`. + +Note that if you want better isolation, it is usually better to use the requirement +`nerdtest.Private` instead of `NoParallel` (see below). + +## Requirements + +`test.Case` has a `Require` property that allow enforcing specific, per-test requirements. + +A `Requirement` is expected to make you `Skip` tests when the environment does not match +expectations. + +Here are a few: +```go +test.Windows // a test runs only on Windows (or Not(Windows)) +test.Linux // a test runs only on Linux +test.Darwin // a test runs only on Darwin +test.OS(name string) // a test runs only on the OS `name` +test.Binary(name string) // a test requires the bin `name` to be in the PATH +test.Not(req Requirement) // a test runs only if the opposite of the requirement `req` is fulfilled +test.Require(req ...Requirement) // a test runs only if all requirements are fulfilled + +nerdtest.Docker // a test only run on Docker - normally used with test.Not(nerdtest.Docker) +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.Build // a test requires buildkit +nerdtest.CGroup // a test requires cgroup +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 +``` + +### About `nerdtest.Private` + +While all requirements above are self-descriptive or obvious, `nerdtest.Private` is a +special case. + +If set, it will run tests inside a dedicated namespace that is private to the test. +Note that subtests by default are going to be set in that same namespace, unless they +ask for private as well, or they reset the `Namespace` config key. + +If the target is Docker - which does not support namespaces - asking for `Private` +will disable parallelization. + +The purpose of private is to provide a truly clean-room environment for tests +that are going to have side effects on others, or that do require an exclusive, pristine +environment. + +Using private is generally preferable to disabling parallelization, as doing the latter +would slow down the run and won't have the same isolation guarantees about the environment. + +## Advanced command customization + +Testing any non-trivial binary likely assume a good amount of custom code +to set up the right default behavior wrt environment, flags, etc. + +To do that, you can pass a `test.Testable` implementation to the `test.Customize` method. + +It basically lets you define your own `CustomizableCommand`, along with a hook to deal with +ambient requirements that is run after `test.Require` and before `test.Setup`. + +`CustomizableCommand` are typically embedding a `test.GenericCommand` and overriding both the +`Run` and `Clone` methods. + +Check the `nerdtest` implementation for details. + +## Utilities + +TBD \ No newline at end of file diff --git a/examples/nerdctl-ipfs-registry-kubernetes/README.md b/examples/nerdctl-ipfs-registry-kubernetes/README.md index 4ff6c1d341d..f5061252b3b 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/README.md +++ b/examples/nerdctl-ipfs-registry-kubernetes/README.md @@ -6,9 +6,9 @@ This directory contains examples of node-to-node image sharing on Kubernetes wit - [`./ipfs-cluster`](./ipfs-cluster): node-to-node image sharing with content replication using ipfs-cluster - [`./ipfs-stargz-snapshotter`](./ipfs-stargz-snapshotter): node-to-node image sharing with lazy pulling using eStargz and Stargz Snapshotter -## Example Dockerfile of `nerdctl ipfs regisry` +## Example Dockerfile of `nerdctl ipfs registry` -The above examples use `nerdctl ipfs regisry` running in a Pod. +The above examples use `nerdctl ipfs registry` running in a Pod. The image is available at [`ghcr.io/stargz-containers/nerdctl-ipfs-registry`](https://github.com/orgs/stargz-containers/packages/container/package/nerdctl-ipfs-registry). The following Dockerfile can be used to build it by yourself. diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/README.md b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/README.md index c56f74ae7b7..05bd41fdc67 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/README.md +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/README.md @@ -36,7 +36,7 @@ Prepare `kind-worker` (1st node) for importing an image to IPFS ```console $ docker exec -it kind-worker /bin/bash (kind-worker)# NERDCTL_VERSION=0.23.0 -(kind-worker)# curl -sSL --output /tmp/nerdctl.tgz https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz +(kind-worker)# curl -o /tmp/nerdctl.tgz -fsSL --proto '=https' --tlsv1.2 https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz (kind-worker)# tar zxvf /tmp/nerdctl.tgz -C /usr/local/bin/ ``` @@ -55,7 +55,7 @@ $ docker exec -it kind-worker /bin/bash The image added to `kind-worker` is shared to other nodes via IPFS. You can run this image on the nodes using the following manifest. -CID of the pushed image is printed when `nerdctl push` is succeded (we assume that the image is added to IPFS as CID `bafkreictyyoysj56v772xbfhyfrcvmgmfpa4vodmqaroz53ytvai7nof6u`). +CID of the pushed image is printed when `nerdctl push` is succeeded (we assume that the image is added to IPFS as CID `bafkreictyyoysj56v772xbfhyfrcvmgmfpa4vodmqaroz53ytvai7nof6u`). ```console $ cat <"${TMPIDFILE}" | base64 -w 0) -ID=$(cat "${TMPIDFILE}" | grep "ID " | sed -E 's/[^:]*: (.*)/\1/') +ID=$(grep "ID " "${TMPIDFILE}" | sed -E 's/[^:]*: (.*)/\1/') rm "${TMPIDFILE}" BOOTSTRAP_PEER_PRIV_KEY=$(echo "${BOOTSTRAP_KEY}" | base64 -w 0) diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/nerdctl-ipfs-registry.yaml b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/nerdctl-ipfs-registry.yaml index 8a7d350f780..3e9a9743f09 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/nerdctl-ipfs-registry.yaml +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-cluster/nerdctl-ipfs-registry.yaml @@ -310,7 +310,7 @@ data: # wait for ipfs daemon ok=false for i in $(seq 100) ; do - if curl localhost:9095/api/v0/id >/dev/null 2>&1 ; then + if curl -fsSL localhost:9095/api/v0/id >/dev/null 2>&1 ; then ok=true break fi diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/README.md b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/README.md index 74648395b08..80db2fcd4f5 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/README.md +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/README.md @@ -49,7 +49,7 @@ Prepare `kind-worker` (1st node) for importing an image to IPFS ```console $ docker exec -it kind-worker /bin/bash (kind-worker)# NERDCTL_VERSION=0.23.0 -(kind-worker)# curl -sSL --output /tmp/nerdctl.tgz https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz +(kind-worker)# curl -o /tmp/nerdctl.tgz -fsSL --proto '=https' --tlsv1.2 https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz (kind-worker)# tar zxvf /tmp/nerdctl.tgz -C /usr/local/bin/ ``` @@ -68,7 +68,7 @@ $ docker exec -it kind-worker /bin/bash The eStargz image added to `kind-worker` is shared to `kind-worker2` via IPFS. You can perform lazy pulling of this eStargz image among nodes using the following manifest. -CID of the pushed image is printed when `nerdctl push` is succeded (we assume that the image is added to IPFS as CID `bafkreidqrxutnnuc3oilje27px5o3gggzrfyomumrprcavr7nquoy3cdje`). +CID of the pushed image is printed when `nerdctl push` is succeeded (we assume that the image is added to IPFS as CID `bafkreidqrxutnnuc3oilje27px5o3gggzrfyomumrprcavr7nquoy3cdje`). ```console diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/bootstrap.yaml.sh b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/bootstrap.yaml.sh index 8905f617171..fc06a298e26 100755 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/bootstrap.yaml.sh +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/bootstrap.yaml.sh @@ -19,12 +19,10 @@ set -eu -o pipefail -for d in ipfs-swarm-key-gen ; do - if ! command -v $d >/dev/null 2>&1 ; then - echo "$d not found" - exit 1 - fi -done +if ! command -v ipfs-swarm-key-gen >/dev/null 2>&1 ; then + echo "ipfs-swarm-key-gen not found" + exit 1 +fi SWARM_KEY=$(ipfs-swarm-key-gen | base64 | tr -d '\n') diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/nerdctl-ipfs-registry.yaml b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/nerdctl-ipfs-registry.yaml index f2f8551104a..6da5096546f 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/nerdctl-ipfs-registry.yaml +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs-stargz-snapshotter/nerdctl-ipfs-registry.yaml @@ -193,7 +193,7 @@ data: # wait for ipfs daemon ok=false for i in $(seq 100) ; do - if curl localhost:5001/api/v0/id >/dev/null 2>&1 ; then + if curl -fsSL localhost:5001/api/v0/id >/dev/null 2>&1 ; then ok=true break fi diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs/README.md b/examples/nerdctl-ipfs-registry-kubernetes/ipfs/README.md index 67907c5783c..53ef383802f 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs/README.md +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs/README.md @@ -34,7 +34,7 @@ Prepare `kind-worker` (1st node) for importing an image to IPFS ```console $ docker exec -it kind-worker /bin/bash (kind-worker)# NERDCTL_VERSION=0.23.0 -(kind-worker)# curl -sSL --output /tmp/nerdctl.tgz https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz +(kind-worker)# curl -fsSL --proto '=https' --tlsv1.2 --output /tmp/nerdctl.tgz https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/nerdctl-${NERDCTL_VERSION}-linux-amd64.tar.gz (kind-worker)# tar zxvf /tmp/nerdctl.tgz -C /usr/local/bin/ ``` @@ -51,7 +51,7 @@ $ docker exec -it kind-worker /bin/bash The image added to `kind-worker` is shared to `kind-worker2` via IPFS. You can run this image on all worker nodes using the following manifest. -CID of the pushed image is printed when `nerdctl push` is succeded (we assume that the image is added to IPFS as CID `bafkreictyyoysj56v772xbfhyfrcvmgmfpa4vodmqaroz53ytvai7nof6u`). +CID of the pushed image is printed when `nerdctl push` succeeded (we assume that the image is added to IPFS as CID `bafkreictyyoysj56v772xbfhyfrcvmgmfpa4vodmqaroz53ytvai7nof6u`). ```console $ cat </dev/null 2>&1 ; then - echo "$d not found" - exit 1 - fi -done +if ! command -v ipfs-swarm-key-gen >/dev/null 2>&1 ; then + echo "ipfs-swarm-key-gen not found" + exit 1 +fi SWARM_KEY=$(ipfs-swarm-key-gen | base64 | tr -d '\n') diff --git a/examples/nerdctl-ipfs-registry-kubernetes/ipfs/nerdctl-ipfs-registry.yaml b/examples/nerdctl-ipfs-registry-kubernetes/ipfs/nerdctl-ipfs-registry.yaml index f2f8551104a..6da5096546f 100644 --- a/examples/nerdctl-ipfs-registry-kubernetes/ipfs/nerdctl-ipfs-registry.yaml +++ b/examples/nerdctl-ipfs-registry-kubernetes/ipfs/nerdctl-ipfs-registry.yaml @@ -193,7 +193,7 @@ data: # wait for ipfs daemon ok=false for i in $(seq 100) ; do - if curl localhost:5001/api/v0/id >/dev/null 2>&1 ; then + if curl -fsSL localhost:5001/api/v0/id >/dev/null 2>&1 ; then ok=true break fi diff --git a/extras/rootless/containerd-rootless-setuptool.sh b/extras/rootless/containerd-rootless-setuptool.sh index 6a26d07f639..27627640d51 100755 --- a/extras/rootless/containerd-rootless-setuptool.sh +++ b/extras/rootless/containerd-rootless-setuptool.sh @@ -29,19 +29,15 @@ set -eu # utility functions INFO() { - # https://github.com/koalaman/shellcheck/issues/1593 - # shellcheck disable=SC2039 - /bin/echo -e "\e[104m\e[97m[INFO]\e[49m\e[39m ${*}" + printf "\e[104m\e[97m[INFO]\e[49m\e[39m %s\n" "$*" } WARNING() { - # shellcheck disable=SC2039 - /bin/echo >&2 -e "\e[101m\e[97m[WARNING]\e[49m\e[39m ${*}" + >&2 printf "\e[101m\e[97m[WARNING]\e[49m\e[39m %s\n" "$*" } ERROR() { - # shellcheck disable=SC2039 - /bin/echo >&2 -e "\e[101m\e[97m[ERROR]\e[49m\e[39m ${*}" + >&2 printf "\e[101m\e[97m[ERROR]\e[49m\e[39m %s\n" "$*" } # constants @@ -139,18 +135,33 @@ cmd_entrypoint_check() { INFO "Requirements are satisfied" } +propagate_env_from() { + pid="$1" + env="$(sed -e "s/\x0/'\n/g" <"/proc/${pid}/environ" | sed -Ee "s/^[^=]*=/export \0'/g")" + shift + for key in "$@"; do + eval "$(echo "$env" | grep "^export ${key=}")" + done +} + # CLI subcommand: "nsenter" cmd_entrypoint_nsenter() { # No need to call init() pid=$(cat "$XDG_RUNTIME_DIR/containerd-rootless/child_pid") - exec nsenter --no-fork --wd="$(pwd)" --preserve-credentials -m -n -U -t "$pid" -- "$@" + n="" + # If RootlessKit is running with `--detach-netns` mode, we do NOT enter the detached netns here + if [ ! -e "$XDG_RUNTIME_DIR/containerd-rootless/netns" ]; then + n="-n" + fi + propagate_env_from "$pid" ROOTLESSKIT_STATE_DIR ROOTLESSKIT_PARENT_EUID ROOTLESSKIT_PARENT_EGID + exec nsenter --no-fork --wd="$(pwd)" --preserve-credentials -m $n -U -t "$pid" -- "$@" } show_systemd_error() { unit="$1" n="20" ERROR "Failed to start ${unit}. Run \`journalctl -n ${n} --no-pager --user --unit ${unit}\` to show the error log." - ERROR "Before retrying installation, you might need to uninstall the current setup: \`$0 uninstall -f ; ${BIN}/rootlesskit rm -rf ${HOME}/.local/share/containerd\`" + ERROR "Before retrying installation, you might need to uninstall the current setup: \`$0 uninstall; ${BIN}/rootlesskit rm -rf ${HOME}/.local/share/containerd\`" } install_systemd_unit() { @@ -215,6 +226,7 @@ cmd_entrypoint_install() { cat <<-EOT | install_systemd_unit "${SYSTEMD_CONTAINERD_UNIT}" [Unit] Description=containerd (Rootless) + Requires=dbus.socket [Service] Environment=PATH=$BIN:/sbin:/usr/sbin:$PATH @@ -255,6 +267,11 @@ cmd_entrypoint_install_buildkit() { ERROR "Install containerd first (\`$ARG0 install\`)" exit 1 fi + BUILDKITD_FLAG="--oci-worker=true --oci-worker-rootless=true --containerd-worker=false" + if buildkitd --help | grep -q bridge; then + # Available since BuildKit v0.13 + BUILDKITD_FLAG="${BUILDKITD_FLAG} --oci-worker-net=bridge" + fi cat <<-EOT | install_systemd_unit "${SYSTEMD_BUILDKIT_UNIT}" [Unit] Description=BuildKit (Rootless) @@ -262,7 +279,7 @@ cmd_entrypoint_install_buildkit() { [Service] Environment=PATH=$BIN:/sbin:/usr/sbin:$PATH - ExecStart="$REALPATH0" nsenter buildkitd + ExecStart="$REALPATH0" nsenter -- buildkitd ${BUILDKITD_FLAG} ExecReload=/bin/kill -s HUP \$MAINPID RestartSec=2 Restart=always @@ -281,32 +298,25 @@ cmd_entrypoint_install_buildkit_containerd() { ERROR "buildkitd (https://github.com/moby/buildkit) needs to be present under \$PATH" exit 1 fi - if [ ! -f "${XDG_CONFIG_HOME}/buildkit/buildkitd.toml" ]; then - mkdir -p "${XDG_CONFIG_HOME}/buildkit" - cat <<-EOF > "${XDG_CONFIG_HOME}/buildkit/buildkitd.toml" - [worker.oci] - enabled = false - - [worker.containerd] - enabled = true - rootless = true - EOF - fi if ! systemctl --user --no-pager status "${SYSTEMD_CONTAINERD_UNIT}" >/dev/null 2>&1; then ERROR "Install containerd first (\`$ARG0 install\`)" exit 1 fi UNIT_NAME=${SYSTEMD_BUILDKIT_UNIT} - BUILDKITD_FLAG= - if [ -n "${CONTAINERD_NAMESPACE:-}" ] ; then + BUILDKITD_FLAG="--oci-worker=false --containerd-worker=true --containerd-worker-rootless=true" + if [ -n "${CONTAINERD_NAMESPACE:-}" ]; then UNIT_NAME="${CONTAINERD_NAMESPACE}-${SYSTEMD_BUILDKIT_UNIT}" BUILDKITD_FLAG="${BUILDKITD_FLAG} --addr=unix://${XDG_RUNTIME_DIR}/buildkit-${CONTAINERD_NAMESPACE}/buildkitd.sock --root=${XDG_DATA_HOME}/buildkit-${CONTAINERD_NAMESPACE} --containerd-worker-namespace=${CONTAINERD_NAMESPACE}" else WARNING "buildkitd has access to images in \"buildkit\" namespace by default. If you want to give buildkitd access to the images in \"default\" namespace, run this command with CONTAINERD_NAMESPACE=default" fi - if [ -n "${CONTAINERD_SNAPSHOTTER:-}" ] ; then + if [ -n "${CONTAINERD_SNAPSHOTTER:-}" ]; then BUILDKITD_FLAG="${BUILDKITD_FLAG} --containerd-worker-snapshotter=${CONTAINERD_SNAPSHOTTER}" fi + if buildkitd --help | grep -q bridge; then + # Available since BuildKit v0.13 + BUILDKITD_FLAG="${BUILDKITD_FLAG} --containerd-worker-net=bridge" + fi cat <<-EOT | install_systemd_unit "${UNIT_NAME}" [Unit] Description=BuildKit (Rootless) @@ -352,7 +362,7 @@ cmd_entrypoint_install_bypass4netnsd() { [Install] WantedBy=default.target EOT - INFO "To use bypass4netnsd, set the \"nerdctl/bypass4netns=true\" label on containers, e.g., \`nerdctl run --label nerdctl/bypass4netns=true\`" + INFO "To use bypass4netnsd, set the \"nerdctl/bypass4netns=true\" annotation on containers, e.g., \`nerdctl run --annotation nerdctl/bypass4netns=true\`" } # CLI subcommand: "install-fuse-overlayfs" @@ -499,8 +509,14 @@ cmd_entrypoint_install_ipfs() { cmd_entrypoint_uninstall() { init uninstall_systemd_unit "${SYSTEMD_BUILDKIT_UNIT}" + if [ -n "${CONTAINERD_NAMESPACE:-}" ]; then + uninstall_systemd_unit "${CONTAINERD_NAMESPACE}-${SYSTEMD_BUILDKIT_UNIT}" + fi uninstall_systemd_unit "${SYSTEMD_FUSE_OVERLAYFS_UNIT}" uninstall_systemd_unit "${SYSTEMD_CONTAINERD_UNIT}" + uninstall_systemd_unit "${SYSTEMD_STARGZ_UNIT}" + uninstall_systemd_unit "${SYSTEMD_IPFS_UNIT}" + uninstall_systemd_unit "${SYSTEMD_BYPASS4NETNSD_UNIT}" INFO "This uninstallation tool does NOT remove containerd binaries and data." INFO "To remove data, run: \`$BIN/rootlesskit rm -rf ${XDG_DATA_HOME}/containerd\`" @@ -511,7 +527,10 @@ cmd_entrypoint_uninstall_buildkit() { init uninstall_systemd_unit "${SYSTEMD_BUILDKIT_UNIT}" INFO "This uninstallation tool does NOT remove data." - INFO "To remove data, run: \`$BIN/rootlesskit rm -rf ${XDG_DATA_HOME}/buildkit" + INFO "To remove data, run: \`$BIN/rootlesskit rm -rf ${XDG_DATA_HOME}/buildkit\`" + if [ -e "${XDG_CONFIG_HOME}/buildkit/buildkitd.toml" ]; then + INFO "You may also want to remove the daemon config: \`rm -f ${XDG_CONFIG_HOME}/buildkit/buildkitd.toml\`" + fi } # CLI subcommand: "uninstall-buildkit-containerd" @@ -519,7 +538,7 @@ cmd_entrypoint_uninstall_buildkit_containerd() { init UNIT_NAME=${SYSTEMD_BUILDKIT_UNIT} BUILDKIT_ROOT="${XDG_DATA_HOME}/buildkit" - if [ -n "${CONTAINERD_NAMESPACE:-}" ] ; then + if [ -n "${CONTAINERD_NAMESPACE:-}" ]; then UNIT_NAME="${CONTAINERD_NAMESPACE}-${SYSTEMD_BUILDKIT_UNIT}" BUILDKIT_ROOT="${XDG_DATA_HOME}/buildkit-${CONTAINERD_NAMESPACE}" fi diff --git a/extras/rootless/containerd-rootless.sh b/extras/rootless/containerd-rootless.sh index d394eeabe56..f569484a574 100755 --- a/extras/rootless/containerd-rootless.sh +++ b/extras/rootless/containerd-rootless.sh @@ -28,7 +28,7 @@ # External dependencies: # * newuidmap and newgidmap needs to be installed. # * /etc/subuid and /etc/subgid needs to be configured for the current user. -# * RootlessKit (>= v0.10.0) needs to be installed. RootlessKit >= v0.14.1 is recommended. +# * RootlessKit (>= v0.10.0) needs to be installed. RootlessKit >= v2.0.0 is recommended. # * Either one of slirp4netns (>= v0.4.0), VPNKit, lxc-user-nic needs to be installed. slirp4netns >= v1.1.7 is recommended. # # Recognized environment variables: @@ -38,27 +38,36 @@ # * CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=(builtin|slirp4netns): the rootlesskit port driver. Defaults to "builtin". # * CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX=(auto|true|false): whether to protect slirp4netns with a dedicated mount namespace. Defaults to "auto". # * CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP=(auto|true|false): whether to protect slirp4netns with seccomp. Defaults to "auto". +# * CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=(auto|true|false): whether to launch rootlesskit with the "detach-netns" mode. +# Defaults to "auto", which is resolved to "true" if RootlessKit >= 2.0 is installed. +# The "detached-netns" mode accelerates `nerdctl (pull|push|build)` and enables `nerdctl run --net=host`, +# however, there is a relatively minor drawback with BuildKit prior to v0.13: +# the host loopback IP address (127.0.0.1) and abstract sockets are exposed to Dockerfile's "RUN" instructions during `nerdctl build` (not `nerdctl run`). +# The drawback is fixed in BuildKit v0.13. Upgrading from a prior version of BuildKit needs removing the old systemd unit: +# `containerd-rootless-setuptool.sh uninstall-buildkit && rm -f ~/.config/buildkit/buildkitd.toml` + +# See also: https://github.com/containerd/nerdctl/blob/main/docs/rootless.md#configuring-rootlesskit set -e -if ! [ -w $XDG_RUNTIME_DIR ]; then +if ! [ -w "$XDG_RUNTIME_DIR" ]; then echo "XDG_RUNTIME_DIR needs to be set and writable" exit 1 fi -if ! [ -w $HOME ]; then +if ! [ -w "$HOME" ]; then echo "HOME needs to be set and writable" exit 1 fi : "${XDG_DATA_HOME:=$HOME/.local/share}" : "${XDG_CONFIG_HOME:=$HOME/.config}" -if [ -z $_CONTAINERD_ROOTLESS_CHILD ]; then +if [ -z "$_CONTAINERD_ROOTLESS_CHILD" ]; then if [ "$(id -u)" = "0" ]; then echo "Must not run as root" exit 1 fi case "$1" in "check" | "install" | "uninstall") - echo "Did you mean 'containerd-rootless-setuptool.sh $@' ?" + echo "Did you mean 'containerd-rootless-setuptool.sh $*' ?" exit 1 ;; esac @@ -69,21 +78,22 @@ if [ -z $_CONTAINERD_ROOTLESS_CHILD ]; then : "${CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER:=builtin}" : "${CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX:=auto}" : "${CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP:=auto}" + : "${CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS:=auto}" net=$CONTAINERD_ROOTLESS_ROOTLESSKIT_NET mtu=$CONTAINERD_ROOTLESS_ROOTLESSKIT_MTU - if [ -z $net ]; then + if [ -z "$net" ]; then if command -v slirp4netns >/dev/null 2>&1; then # If --netns-type is present in --help, slirp4netns is >= v0.4.0. if slirp4netns --help | grep -qw -- --netns-type; then net=slirp4netns - if [ -z $mtu ]; then + if [ -z "$mtu" ]; then mtu=65520 fi else echo "slirp4netns found but seems older than v0.4.0. Falling back to VPNKit." fi fi - if [ -z $net ]; then + if [ -z "$net" ]; then if command -v vpnkit >/dev/null 2>&1; then net=vpnkit else @@ -92,7 +102,7 @@ if [ -z $_CONTAINERD_ROOTLESS_CHILD ]; then fi fi fi - if [ -z $mtu ]; then + if [ -z "$mtu" ]; then mtu=1500 fi @@ -107,6 +117,25 @@ if [ -z $_CONTAINERD_ROOTLESS_CHILD ]; then export _CONTAINERD_ROOTLESS_SELINUX fi fi + + case "$CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS" in + auto) + if rootlesskit --help | grep -qw -- "--detach-netns"; then + CONTAINERD_ROOTLESS_ROOTLESSKIT_FLAGS="--detach-netns $CONTAINERD_ROOTLESS_ROOTLESSKIT_FLAGS" + fi + ;; + 1 | true) + CONTAINERD_ROOTLESS_ROOTLESSKIT_FLAGS="--detach-netns $CONTAINERD_ROOTLESS_ROOTLESSKIT_FLAGS" + ;; + 0 | false) + # NOP + ;; + *) + echo "Unknown CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS value: $CONTAINERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS" + exit 1 + ;; + esac + # Re-exec the script via RootlessKit, so as to create unprivileged {user,mount,network} namespaces. # # --copy-up allows removing/creating files in the directories by creating tmpfs and symlinks @@ -115,18 +144,19 @@ if [ -z $_CONTAINERD_ROOTLESS_CHILD ]; then # (by either systemd-networkd or NetworkManager) # * /run: copy-up is required so that we can create /run/containerd (hardcoded) in our namespace # * /var/lib: copy-up is required so that we can create /var/lib/containerd in our namespace + # shellcheck disable=SC2086 exec rootlesskit \ - --state-dir=$CONTAINERD_ROOTLESS_ROOTLESSKIT_STATE_DIR \ - --net=$net --mtu=$mtu \ - --slirp4netns-sandbox=$CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX \ - --slirp4netns-seccomp=$CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP \ - --disable-host-loopback --port-driver=$CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER \ + --state-dir="$CONTAINERD_ROOTLESS_ROOTLESSKIT_STATE_DIR" \ + --net="$net" --mtu="$mtu" \ + --slirp4netns-sandbox="$CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX" \ + --slirp4netns-seccomp="$CONTAINERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP" \ + --disable-host-loopback --port-driver="$CONTAINERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER" \ --copy-up=/etc --copy-up=/run --copy-up=/var/lib \ --propagation=rslave \ $CONTAINERD_ROOTLESS_ROOTLESSKIT_FLAGS \ - $0 $@ + "$0" "$@" else - [ $_CONTAINERD_ROOTLESS_CHILD = 1 ] + [ "$_CONTAINERD_ROOTLESS_CHILD" = 1 ] # Remove the *symlinks* for the existing files in the parent namespace if any, # so that we can create our own files in our mount namespace. # The actual files in the parent namespace are *not removed* by this rm command. @@ -164,5 +194,5 @@ else chcon system_u:object_r:iptables_var_run_t:s0 /run fi - exec containerd $@ + exec containerd "$@" fi diff --git a/go.mod b/go.mod index 0bc85eb2f89..c27a14d845a 100644 --- a/go.mod +++ b/go.mod @@ -1,132 +1,149 @@ -module github.com/containerd/nerdctl +module github.com/containerd/nerdctl/v2 -go 1.19 +go 1.22.7 + +// FIXME: +// github.com/docker/docker/pkg/sysinfo has been replaced by a fork kept under ./pkg2/sysinfo +// as Moby is not going to move to containerd v2 anytime soon or fix these transient dependencies. +// We should still move back to upstream in the future, and remove our copy. require ( - github.com/Masterminds/semver/v3 v3.2.1 - github.com/Microsoft/go-winio v0.6.1 - github.com/Microsoft/hcsshim v0.10.0-rc.8 - github.com/compose-spec/compose-go v1.14.0 - github.com/containerd/accelerated-container-image v0.6.7 - github.com/containerd/cgroups/v3 v3.0.1 - github.com/containerd/console v1.0.3 - github.com/containerd/containerd v1.7.2 - github.com/containerd/continuity v0.4.1 - github.com/containerd/go-cni v1.1.9 - github.com/containerd/imgcrypt v1.1.7 - github.com/containerd/nydus-snapshotter v0.9.0 - github.com/containerd/stargz-snapshotter v0.14.3 - github.com/containerd/stargz-snapshotter/estargz v0.14.3 - github.com/containerd/stargz-snapshotter/ipfs v0.14.3 - github.com/containerd/typeurl/v2 v2.1.1 - github.com/containernetworking/cni v1.1.2 - github.com/containernetworking/plugins v1.3.0 - github.com/coreos/go-iptables v0.6.0 + github.com/Masterminds/semver/v3 v3.3.1 + github.com/Microsoft/go-winio v0.6.2 + github.com/Microsoft/hcsshim v0.12.9 + github.com/compose-spec/compose-go/v2 v2.4.7 + github.com/containerd/accelerated-container-image v1.2.3 + github.com/containerd/cgroups/v3 v3.0.5 + github.com/containerd/console v1.0.4 + github.com/containerd/containerd/api v1.8.0 + github.com/containerd/containerd/v2 v2.0.2 + github.com/containerd/continuity v0.4.5 + github.com/containerd/errdefs v1.0.0 + github.com/containerd/fifo v1.1.0 + github.com/containerd/go-cni v1.1.12 + github.com/containerd/imgcrypt/v2 v2.0.0 + github.com/containerd/log v0.1.0 + github.com/containerd/nydus-snapshotter v0.15.0 + github.com/containerd/platforms v1.0.0-rc.1 + github.com/containerd/stargz-snapshotter v0.16.3 + github.com/containerd/stargz-snapshotter/estargz v0.16.3 + github.com/containerd/stargz-snapshotter/ipfs v0.16.3 + github.com/containerd/typeurl/v2 v2.2.3 + github.com/containernetworking/cni v1.2.3 + github.com/containernetworking/plugins v1.5.1 + github.com/coreos/go-iptables v0.8.0 github.com/coreos/go-systemd/v22 v22.5.0 - github.com/cyphar/filepath-securejoin v0.2.3 - github.com/docker/cli v24.0.2+incompatible - github.com/docker/docker v24.0.2+incompatible - github.com/docker/go-connections v0.4.0 + github.com/cyphar/filepath-securejoin v0.4.0 + github.com/distribution/reference v0.6.0 + github.com/docker/cli v27.5.0+incompatible + github.com/docker/docker v27.5.0+incompatible + github.com/docker/go-connections v0.5.0 github.com/docker/go-units v0.5.0 - github.com/fahedouch/go-logrotate v0.1.3 - github.com/fatih/color v1.15.0 + github.com/fahedouch/go-logrotate v0.2.1 + github.com/fatih/color v1.18.0 github.com/fluent/fluent-logger-golang v1.9.0 - github.com/hashicorp/go-multierror v1.1.1 + github.com/fsnotify/fsnotify v1.8.0 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/ipfs/go-cid v0.4.1 - github.com/mattn/go-isatty v0.0.19 - github.com/mitchellh/mapstructure v1.5.0 - github.com/moby/sys/mount v0.3.3 - github.com/moby/sys/signal v0.7.0 - github.com/moby/term v0.5.0 + github.com/klauspost/compress v1.17.11 + github.com/mattn/go-isatty v0.0.20 + github.com/moby/sys/mount v0.3.4 + github.com/moby/sys/mountinfo v0.7.2 + github.com/moby/sys/signal v0.7.1 + github.com/moby/sys/userns v0.1.0 + github.com/moby/term v0.5.2 + github.com/muesli/cancelreader v0.2.2 github.com/opencontainers/go-digest v1.0.0 - github.com/opencontainers/image-spec v1.1.0-rc3 - github.com/opencontainers/runtime-spec v1.1.0-rc.3 - github.com/pelletier/go-toml v1.9.5 - github.com/rootless-containers/bypass4netns v0.3.0 - github.com/rootless-containers/rootlesskit v1.1.1 - github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.7.0 + github.com/opencontainers/image-spec v1.1.0 + github.com/opencontainers/runtime-spec v1.2.0 + github.com/pelletier/go-toml/v2 v2.2.3 + github.com/rootless-containers/bypass4netns v0.4.2 + github.com/rootless-containers/rootlesskit/v2 v2.3.2 + github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 - github.com/tidwall/gjson v1.14.4 - github.com/vishvananda/netlink v1.2.1-beta.2 - github.com/vishvananda/netns v0.0.4 + github.com/vishvananda/netlink v1.3.0 + github.com/vishvananda/netns v0.0.5 github.com/yuchanns/srslog v1.1.0 - golang.org/x/crypto v0.10.0 - golang.org/x/net v0.11.0 - golang.org/x/sync v0.3.0 - golang.org/x/sys v0.9.0 - golang.org/x/term v0.9.0 - golang.org/x/text v0.10.0 + go.uber.org/mock v0.5.0 + golang.org/x/crypto v0.32.0 + golang.org/x/net v0.34.0 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.29.0 + golang.org/x/term v0.28.0 + golang.org/x/text v0.21.0 gopkg.in/yaml.v3 v3.0.1 - gotest.tools/v3 v3.4.0 + gotest.tools/v3 v3.5.1 ) require ( - github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect - github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20221215162035-5330a85ea652 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect - github.com/cilium/ebpf v0.9.1 // indirect - github.com/containerd/cgroups v1.1.0 // indirect - github.com/containerd/fifo v1.1.0 // indirect - github.com/containerd/ttrpc v1.2.2 // indirect - github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 // indirect - github.com/containers/ocicrypt v1.1.7 // indirect - github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa // indirect - github.com/djherbis/times v1.5.0 // indirect - github.com/docker/docker-credential-helpers v0.7.0 // indirect - github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect - github.com/frankban/quicktest v1.14.2 // indirect - github.com/go-logr/logr v1.2.4 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect + github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 // indirect + 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 + github.com/containerd/plugin v1.0.0 // indirect + github.com/containerd/ttrpc v1.2.7 // indirect + github.com/containers/ocicrypt v1.2.1 // indirect + 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.4 // indirect + github.com/go-logr/logr v1.4.2 // 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/protobuf v1.5.3 // indirect - github.com/google/go-cmp v0.5.9 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/imdario/mergo v0.3.15 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.16.6 - github.com/klauspost/cpuid/v2 v2.1.1 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect - github.com/minio/sha256-simd v1.0.0 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect - github.com/moby/sys/mountinfo v0.6.2 // indirect - github.com/moby/sys/sequential v0.5.0 // indirect - github.com/moby/sys/symlink v0.2.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/symlink v0.3.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect - github.com/multiformats/go-base36 v0.1.0 // indirect - github.com/multiformats/go-multiaddr v0.8.0 // indirect - github.com/multiformats/go-multibase v0.1.1 // indirect - github.com/multiformats/go-multihash v0.2.1 // indirect - github.com/multiformats/go-varint v0.0.6 // indirect - github.com/opencontainers/runc v1.1.7 // indirect - github.com/opencontainers/selinux v1.11.0 // indirect - github.com/philhofer/fwd v1.1.1 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr v0.13.0 // 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.11.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/pkg/errors v0.9.1 // indirect + github.com/sasha-s/go-deadlock v0.3.5 // indirect + 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-20201008174630-78d3cae3a980 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - github.com/tinylib/msgp v1.1.6 // indirect - github.com/vbatts/tar-split v0.11.2 // indirect + github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect + 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 - go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel v1.14.0 // indirect - go.opentelemetry.io/otel/trace v1.14.0 // indirect - golang.org/x/mod v0.9.0 // indirect - golang.org/x/tools v0.7.0 // indirect - google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect - google.golang.org/grpc v1.54.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect - gopkg.in/square/go-jose.v2 v2.5.1 // indirect - lukechampine.com/blake3 v1.1.7 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect + golang.org/x/mod v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.2 // indirect + lukechampine.com/blake3 v1.3.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect + tags.cncf.io/container-device-interface v0.8.0 // indirect + tags.cncf.io/container-device-interface/specs-go v0.8.0 // indirect ) diff --git a/go.sum b/go.sum index 5eeffc995b1..39fe1cf07b5 100644 --- a/go.sum +++ b/go.sum @@ -1,1532 +1,487 @@ -bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= -bazil.org/fuse v0.0.0-20200407214033-5883e5a4b512/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20210715213245-6c3934b029d8/go.mod h1:CzsSbkDixRphAF5hS6wbMKq0eI6ccJRb7/A0M6JBnwg= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 h1:EKPd1INOIyr5hWOWhvpmQpY6tKjeG0hT1s3AMC/9fic= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1/go.mod h1:VzwV+t+dZ9j/H867F1M2ziD+yLHtB46oM35FxxMJ4d0= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20221215162035-5330a85ea652 h1:+vTEFqeoeur6XSq06bs+roX3YiT49gUniJK7Zky7Xjg= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20221215162035-5330a85ea652/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= -github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= -github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= -github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= -github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= -github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +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/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2 h1:dIScnXFlF784X79oi7MzVT6GWqr/W1uUt0pB5CsDs9M= +github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20231105174938-2b5cbb29f3e2/go.mod h1:gCLVsLfv1egrcZu+GoJATN5ts75F2s62ih/457eWzOw= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= -github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= -github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= -github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= -github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8= -github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg= -github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00= -github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600= -github.com/Microsoft/hcsshim v0.8.20/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4= -github.com/Microsoft/hcsshim v0.8.21/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4= -github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg= -github.com/Microsoft/hcsshim v0.9.2/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc= -github.com/Microsoft/hcsshim v0.9.4/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc= -github.com/Microsoft/hcsshim v0.10.0-rc.8 h1:YSZVvlIIDD1UxQpJp0h+dnpLUw+TrY0cx8obKsp3bek= -github.com/Microsoft/hcsshim v0.10.0-rc.8/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= -github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= -github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= -github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= -github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= -github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= -github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +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/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.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6lLg= +github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= +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/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= -github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= -github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= -github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= -github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= -github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= -github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= -github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= -github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= -github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= -github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= -github.com/cilium/ebpf v0.9.1 h1:64sn2K3UKw8NbP/blsixRpF3nXuyhz/VjRlRzvlBRu4= -github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= +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/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= -github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= -github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= -github.com/compose-spec/compose-go v1.14.0 h1:/+tQxBEPIrfsi87Qh7/VjMzcJN3BRNER/RO71ku+u6E= -github.com/compose-spec/compose-go v1.14.0/go.mod h1:m0o4G6MQDHjjz9rY7No9FpnNi+9sKic262rzrwuCqic= -github.com/containerd/accelerated-container-image v0.6.7 h1:QDO12lgUubiUq0ogMzcL6CdSxkzFOX7vVaSIXAJ9EaM= -github.com/containerd/accelerated-container-image v0.6.7/go.mod h1:a7MYTlNhR4+GGpXD7wuNSgxrwC2wE2rgUfCvef+FQzg= -github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE= -github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU= -github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= -github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= -github.com/containerd/btrfs v0.0.0-20201111183144-404b9149801e/go.mod h1:jg2QkJcsabfHugurUvvPhS3E08Oxiuh5W/g1ybB4e0E= -github.com/containerd/btrfs v0.0.0-20210316141732-918d888fb676/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss= -github.com/containerd/btrfs v1.0.0/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss= -github.com/containerd/cgroups v0.0.0-20190717030353-c4b9ac5c7601/go.mod h1:X9rLEHIqSf/wfK8NsPqxJmeZgW4pcfzdXITDrUSJ6uI= -github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= -github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM= -github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= -github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= -github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= -github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU= -github.com/containerd/cgroups v1.0.3/go.mod h1:/ofk34relqNjSGyqPrmEULrO4Sc8LJhvJmWbUCUKqj8= -github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= -github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= -github.com/containerd/cgroups/v3 v3.0.1 h1:4hfGvu8rfGIwVIDd+nLzn/B9ZXx4BcCjzt5ToenJRaE= -github.com/containerd/cgroups/v3 v3.0.1/go.mod h1:/vtwk1VXrtoa5AaZLkypuOJgA/6DyPMZHJPGQNtlHnw= -github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= -github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= -github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE= -github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= -github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= -github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ= -github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU= -github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI= -github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s= -github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= -github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c= -github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s= -github.com/containerd/containerd v1.6.1/go.mod h1:1nJz5xCZPusx6jJU8Frfct988y0NpumIq9ODB0kLtoE= -github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0= -github.com/containerd/containerd v1.7.2 h1:UF2gdONnxO8I6byZXDi5sXWiWvlW3D/sci7dTQimEJo= -github.com/containerd/containerd v1.7.2/go.mod h1:afcz74+K10M/+cjGHIVQrCt3RAQhUSCAjJ9iMYhhkuI= -github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= -github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= -github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y= -github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ= -github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM= -github.com/containerd/continuity v0.2.2/go.mod h1:pWygW9u7LtS1o4N/Tn0FoCFDIXZ7rxcMX7HX1Dmibvk= -github.com/containerd/continuity v0.4.1 h1:wQnVrjIyQ8vhU2sgOiL5T07jo+ouqc2bnKsv5/EqGhU= -github.com/containerd/continuity v0.4.1/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= -github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= -github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= -github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= -github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= -github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= -github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/compose-spec/compose-go/v2 v2.4.7 h1:WNpz5bIbKG+G+w9pfu72B1ZXr+Og9jez8TMEo8ecXPk= +github.com/compose-spec/compose-go/v2 v2.4.7/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc= +github.com/containerd/accelerated-container-image v1.2.3 h1:tAIoP7Z7b2xGhb7QCM5Fa+2xqWfPqRmyi5lodbsGGRA= +github.com/containerd/accelerated-container-image v1.2.3/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.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVMyTRdsD2bS0= +github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc= +github.com/containerd/containerd/v2 v2.0.2 h1:GmH/tRBlTvrXOLwSpWE2vNAm8+MqI6nmxKpKBNKY8Wc= +github.com/containerd/containerd/v2 v2.0.2/go.mod h1:wIqEvQ/6cyPFUGJ5yMFanspPabMLor+bF865OHvNTTI= +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= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +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.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU= -github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk= -github.com/containerd/go-cni v1.1.0/go.mod h1:Rflh2EJ/++BA2/vY5ao3K6WJRR/bZKsX123aPk+kUtA= -github.com/containerd/go-cni v1.1.3/go.mod h1:Rflh2EJ/++BA2/vY5ao3K6WJRR/bZKsX123aPk+kUtA= -github.com/containerd/go-cni v1.1.6/go.mod h1:BWtoWl5ghVymxu6MBjg79W9NZrCRyHIdUtk4cauMe34= -github.com/containerd/go-cni v1.1.9 h1:ORi7P1dYzCwVM6XPN4n3CbkuOx/NZ2DOqy+SHRdo9rU= -github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM= -github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= -github.com/containerd/go-runc v0.0.0-20190911050354-e029b79d8cda/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= -github.com/containerd/go-runc v0.0.0-20200220073739-7016d3ce2328/go.mod h1:PpyHrqVs8FTi9vpyHwPwiNEGaACDxT/N/pLcvMSRA9g= -github.com/containerd/go-runc v0.0.0-20201020171139-16b287bc67d0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= -github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= -github.com/containerd/imgcrypt v1.0.1/go.mod h1:mdd8cEPW7TPgNG4FpuP3sGBiQ7Yi/zak9TYCG3juvb0= -github.com/containerd/imgcrypt v1.0.4-0.20210301171431-0ae5c75f59ba/go.mod h1:6TNsg0ctmizkrOgXRNQjAPFWpMYRWuiB6dSF4Pfa5SA= -github.com/containerd/imgcrypt v1.1.1-0.20210312161619-7ed62a527887/go.mod h1:5AZJNI6sLHJljKuI9IHnw1pWqo/F0nGDOuR9zgTs7ow= -github.com/containerd/imgcrypt v1.1.1/go.mod h1:xpLnwiQmEUJPvQoAapeb2SNCxz7Xr6PJrXQb0Dpc4ms= -github.com/containerd/imgcrypt v1.1.3/go.mod h1:/TPA1GIDXMzbj01yd8pIbQiLdQxed5ue1wb8bP7PQu4= -github.com/containerd/imgcrypt v1.1.4/go.mod h1:LorQnPtzL/T0IyCeftcsMEO7AqxUDbdO8j/tSUpgxvo= -github.com/containerd/imgcrypt v1.1.7 h1:WSf9o9EQ0KGHiUx2ESFZ+PKf4nxK9BcvV/nJDX8RkB4= -github.com/containerd/imgcrypt v1.1.7/go.mod h1:FD8gqIcX5aTotCtOmjeCsi3A1dHmTZpnMISGKSczt4k= -github.com/containerd/nri v0.0.0-20201007170849-eb1350a75164/go.mod h1:+2wGSDGFYfE5+So4M5syatU0N0f0LbWpuqyMi4/BE8c= -github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY= -github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY= -github.com/containerd/nydus-snapshotter v0.9.0 h1:f0Tr3srVKDlURgLG/Kocy4WQIYsmSoc8ihHxdzfB2S0= -github.com/containerd/nydus-snapshotter v0.9.0/go.mod h1:xEsAzeM0gZEW6POBPOa+1X7EThYsEJNWnO/fhf2moYU= -github.com/containerd/stargz-snapshotter v0.14.3 h1:OTUVZoPSPs8mGgmQUE1dqw3WX/3nrsmsurW7UPLWl1U= -github.com/containerd/stargz-snapshotter v0.14.3/go.mod h1:j2Ya4JeA5gMZJr8BchSkPjlcCEh++auAxp4nidPI6N0= -github.com/containerd/stargz-snapshotter/estargz v0.4.1/go.mod h1:x7Q9dg9QYb4+ELgxmo4gBUeJB0tl5dqH1Sdz0nJU1QM= -github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= -github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= -github.com/containerd/stargz-snapshotter/ipfs v0.14.3 h1:Y9jAdsjvZyG30dnEUvfMyV0FOnfs/pdEuYcxYMTDGSM= -github.com/containerd/stargz-snapshotter/ipfs v0.14.3/go.mod h1:Y7oQmTVPao0mE8S6WqIKVTnd59FNW55Gy0r/RIGySew= -github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= -github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= -github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8= -github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= -github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= -github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ= -github.com/containerd/ttrpc v1.2.2 h1:9vqZr0pxwOF5koz6N0N3kJ0zDHokrcPxIR/ZR2YFtOs= -github.com/containerd/ttrpc v1.2.2/go.mod h1:sIT6l32Ph/H9cvnJsfXM5drIVzTr5A2flTf1G5tYZak= -github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= -github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk= -github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg= -github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= -github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67 h1:rQvjv7gRi6Ki/NS/U9oLZFhqyk4dh/GH2M3o/4BRkMM= -github.com/containerd/typeurl v1.0.3-0.20220422153119-7f6e6d160d67/go.mod h1:HDkcKOXRnX6yKnXv3P0QrogFi0DoiauK/LpQi961f0A= -github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= -github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= -github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw= -github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y= -github.com/containerd/zfs v0.0.0-20210315114300-dde8f0fda960/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= -github.com/containerd/zfs v0.0.0-20210324211415-d5c4544f0433/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= -github.com/containerd/zfs v1.0.0/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= -github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= -github.com/containernetworking/cni v0.8.0/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= -github.com/containernetworking/cni v0.8.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= -github.com/containernetworking/cni v1.0.1/go.mod h1:AKuhXbN5EzmD4yTNtfSsX3tPcmtrBI6QcRV0NiNt15Y= -github.com/containernetworking/cni v1.1.1/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw= -github.com/containernetworking/cni v1.1.2 h1:wtRGZVv7olUHMOqouPpn3cXJWpJgM6+EUl31EQbXALQ= -github.com/containernetworking/cni v1.1.2/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw= -github.com/containernetworking/plugins v0.8.6/go.mod h1:qnw5mN19D8fIwkqW7oHHYDHVlzhJpcY6TQxn/fUyDDM= -github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRDjeJr6FLK6vuiUwoH7P8= -github.com/containernetworking/plugins v1.0.1/go.mod h1:QHCfGpaTwYTbbH+nZXKVTxNBDZcxSOplJT5ico8/FLE= -github.com/containernetworking/plugins v1.1.1/go.mod h1:Sr5TH/eBsGLXK/h71HeLfX19sZPp3ry5uHSkI4LPxV8= -github.com/containernetworking/plugins v1.3.0 h1:QVNXMT6XloyMUoO2wUOqWTC1hWFV62Q6mVDp5H1HnjM= -github.com/containernetworking/plugins v1.3.0/go.mod h1:Pc2wcedTQQCVuROOOaLBPPxrEXqqXBFt3cZ+/yVg6l0= -github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc= -github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4= -github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY= -github.com/containers/ocicrypt v1.1.2/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY= -github.com/containers/ocicrypt v1.1.3/go.mod h1:xpdkbVAuaH3WzbEabUd5yDsl9SwJA5pABH85425Es2g= -github.com/containers/ocicrypt v1.1.6/go.mod h1:WgjxPWdTJMqYMjf3M6cuIFFA1/MpyyhIM99YInA+Rvc= -github.com/containers/ocicrypt v1.1.7 h1:thhNr4fu2ltyGz8aMx8u48Ae0Pnbip3ePP9/mzkZ/3U= -github.com/containers/ocicrypt v1.1.7/go.mod h1:7CAhjcj2H8AYp5YvEie7oVSK2AhBY8NscCYRawuDNtw= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= -github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= -github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk= -github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= -github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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-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.0 h1:vd2ByN6cXeearzXCQljH1eYe77FgFO5/B9+dK14mng0= +github.com/containerd/imgcrypt/v2 v2.0.0/go.mod h1:S4kOVvPZRerVueZULagcwkJK7sKc/wQI/ixcmyj26uY= +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.0 h1:RqZRs1GPeM6T3wmuxJV9u+2Rg4YETVMwTmiDeX+iWC8= +github.com/containerd/nydus-snapshotter v0.15.0/go.mod h1:biq0ijpeZe0I5yZFSJyHzFSjjRZQ7P7y/OuHyd7hYOw= +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/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/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.2.3 h1:hhOcjNVUQTnzdRJ6alC5XF+wd9mfGIUaj8FuJbEslXM= +github.com/containernetworking/cni v1.2.3/go.mod h1:DuLgF+aPd3DzcTQTtp/Nvl1Kim23oFKdm2okJzBQA5M= +github.com/containernetworking/plugins v1.5.1 h1:T5ji+LPYjjgW0QM+KyrigZbLsZ8jaX+E5J/EcKOE4gQ= +github.com/containernetworking/plugins v1.5.1/go.mod h1:MIQfgMayGuHYs0XdNudf31cLLAC+i242hNm6KuDGqCM= +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/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= -github.com/cyphar/filepath-securejoin v0.2.3 h1:YX6ebbZCZP7VkM3scTTokDgBL2TY741X51MTk3ycuNI= -github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= -github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= -github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s= -github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8= -github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.4.0 h1:PioTG9TBRSApBpYGnDU8HC+miIsX8vitBH9LGNNMoLQ= +github.com/cyphar/filepath-securejoin v0.4.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= -github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa h1:L9Ay/slwQ4ERSPaurC+TVkZrM0K98GNrEEo1En3e8as= -github.com/distribution/distribution/v3 v3.0.0-20230214150026-36d8c594d7aa/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= -github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= -github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= -github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= -github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/cli v24.0.2+incompatible h1:QdqR7znue1mtkXIJ+ruQMGQhpw2JzMJLRXp6zpzF6tM= -github.com/docker/cli v24.0.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= -github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v24.0.2+incompatible h1:eATx+oLz9WdNVkQrr0qjQ8HvRJ4bOOxfzEo8R+dA3cg= -github.com/docker/docker v24.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= -github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= -github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= -github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= -github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= -github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= -github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +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 v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM= +github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v27.5.0+incompatible h1:um++2NcQtGRTz5eEgO6aJimo6/JxrTXC941hd05JO6U= +github.com/docker/docker v27.5.0+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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= -github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= -github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= -github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fahedouch/go-logrotate v0.1.3 h1:V5VGDXfKjzjuISflMsxJqKsHggpZE/7iNyxfyNlu5A8= -github.com/fahedouch/go-logrotate v0.1.3/go.mod h1:S8a52JNmJe9t7XdO5Y3NZ36Va3HcVQrI/HBJLpvnc9w= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fahedouch/go-logrotate v0.2.1 h1:Q0Hk9Kp/Y4iwy9uR9e/60fEoxGhvfk8MG7WwtL9aarM= +github.com/fahedouch/go-logrotate v0.2.1/go.mod h1:Mmyex1f9fGXBNnhS9uHsbnO9BGvADF4VGqVnqAJalgc= +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/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.2 h1:SPb1KFFmM+ybpEjPUhCCkZOM5xlovT5UbrMvWnXyBns= -github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= -github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= -github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= -github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= -github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.1/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/stdr v1.2.0/go.mod h1:YkVgnZu1ZjjL7xTxrfm/LLZBfkhTqSR1ydtm6jTKKwI= +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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= -github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= -github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= -github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= -github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= -github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= -github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= -github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= -github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= -github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= -github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU= -github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 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-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 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/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/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.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20230323073829-e72429f035bd h1:r8yyd+DJDmsUhGrRBxH5Pj7KeFK5l+Y3FsgT8keqKtk= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= -github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= -github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +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-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 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/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= -github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/intel/goresctrl v0.2.0/go.mod h1:+CZdzouYFn5EsxgqAQTEzMfwKwuc0fVdMrT9FCCAVRQ= github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= -github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= -github.com/j-keck/arping v1.0.2/go.mod h1:aJbELhR92bSk7tp79AWM/ftfc90EfEi2bQJrbBFOsPw= -github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +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.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk= -github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.1.1 h1:t0wUqjowdm8ezddV5k0tLWVklVuvLJpoHeb4WBdydm0= -github.com/klauspost/cpuid/v2 v2.1.1/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +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= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= -github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +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/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +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/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= -github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= -github.com/moby/sys/mount v0.3.3/go.mod h1:PBaEorSNTLG5t/+4EgukEQVlAvVEc6ZjTySwKdqp5K0= -github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= -github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= -github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= -github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= -github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= -github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= -github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= -github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= -github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= -github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= -github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= -github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= -github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= +github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= +github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrNU= +github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +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.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= -github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= -github.com/multiformats/go-multiaddr v0.8.0 h1:aqjksEcqK+iD/Foe1RRFsGZh8+XFiGo7FgUCZlpv3LU= -github.com/multiformats/go-multiaddr v0.8.0/go.mod h1:Fs50eBDWvZu+l3/9S6xAE7ZYj6yhxlvaVZjakWN7xRs= -github.com/multiformats/go-multibase v0.1.1 h1:3ASCDsuLX8+j4kx58qnJ4YFq/JWTJpCyDW27ztsVTOI= -github.com/multiformats/go-multibase v0.1.1/go.mod h1:ZEjHE+IsUrgp5mhlEAYjMtZwK1k4haNkcaPg9aoe1a8= -github.com/multiformats/go-multihash v0.2.1 h1:aem8ZT0VA2nCHHk7bPJ1BjUbHNciqZC/d16Vve9l108= -github.com/multiformats/go-multihash v0.2.1/go.mod h1:WxoMcYG85AZVQUyRyo9s4wULvW5qrI9vb2Lt6evduFc= -github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= -github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= -github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= -github.com/networkplumbing/go-nft v0.2.0/go.mod h1:HnnM+tYvlGAsMU7yoYwXEVLLiDW9gdMmb5HoGcwpuQs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= -github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= -github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +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-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.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= 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.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.2-0.20211117181255-693428a734f5/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= -github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= -github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= -github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0= -github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0= -github.com/opencontainers/runc v1.1.0/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= -github.com/opencontainers/runc v1.1.2/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= -github.com/opencontainers/runc v1.1.7 h1:y2EZDS8sNng4Ksf0GUYNhKbTShZJPJg1FiXJNH/uoCk= -github.com/opencontainers/runc v1.1.7/go.mod h1:CbUumNnWCuTGFukNXahoo/RFBZvDAgRh/smNYNOhA50= -github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.1.0-rc.3 h1:l04uafi6kxByhbxev7OWiuUv0LZxEsYUfDWZ6bztAuU= -github.com/opencontainers/runtime-spec v1.1.0-rc.3/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= -github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE= -github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo= -github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8= -github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= -github.com/opencontainers/selinux v1.10.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= -github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= -github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ= -github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +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.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/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.11.1 h1:nHFvthhM0qY8/m+vfhJylliSshm8G1jJ2jDMcgULaH8= +github.com/opencontainers/selinux v1.11.1/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= -github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +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/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.0-20190522114515-bc1a522cf7b1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rootless-containers/bypass4netns v0.3.0 h1:UwI55zWDZz7OGyN4YWgfCKdsI58VGY7OlghcLdxJX10= -github.com/rootless-containers/bypass4netns v0.3.0/go.mod h1:IXHPjkQlJRygNBCN0hSSR3ITX6kDKr3aAaGHx6APd+g= -github.com/rootless-containers/rootlesskit v1.1.1 h1:F5psKWoWY9/VjZ3ifVcaosjvFZJOagX85U22M0/EQZE= -github.com/rootless-containers/rootlesskit v1.1.1/go.mod h1:UD5GoA3dqKCJrnvnhVgQQnweMF2qZnf9KLw8EewcMZI= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +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.2 h1:QZk7sKU3+B8UHretEeIg6NSTTpj0o4iHGNhNbJBnHOU= +github.com/rootless-containers/rootlesskit/v2 v2.3.2/go.mod h1:RL7YzL02nA2d8HAzt5d1nZnuiAeudQ4oym+HF/7sk7U= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= -github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= -github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= -github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +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/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980 h1:lIOOHPEbXzO3vnmx2gok1Tfs31Q8GQqKLc8vVqyQq/I= -github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= -github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 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 v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= -github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +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/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tinylib/msgp v1.1.6 h1:i+SbKraHhnrf9M5MYmvQhFnbLhAXSDWF8WWsuyRdocw= -github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= -github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/vbatts/tar-split v0.11.2 h1:Via6XqJr0hceW4wff3QRzD5gAk/tatMw/4ZA7cTlIME= -github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI= -github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= -github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= -github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= -github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= -github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= -github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= -github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI= -github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= -github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +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.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= +github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= +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= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuchanns/srslog v1.1.0 h1:CEm97Xxxd8XpJThE0gc/XsqUGgPufh5u5MUjC27/KOk= github.com/yuchanns/srslog v1.1.0/go.mod h1:HsLjdv3XV02C3kgBW2bTyW6i88OQE+VYJZIxrPKPPak= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= -github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= -github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= -go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.etcd.io/etcd/pkg/v3 v3.5.0/go.mod h1:UzJGatBQ1lXChBkQF0AuAtkRQMYnHubxAEYIrC3MSsE= -go.etcd.io/etcd/raft/v3 v3.5.0/go.mod h1:UFOHSIvO/nKwd4lhkwabrTD3cqW5yVyYYf/KlD00Szc= -go.etcd.io/etcd/server/v3 v3.5.0/go.mod h1:3Ah5ruV+M+7RZr0+Y/5mNLwC+eQlni+mQmOVdCRJoS4= -go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M= -go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0/go.mod h1:vEhqr0m4eTc+DWxfsXoXue2GBgV2uUwVznkGIHW/e5w= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4= -go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= -go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs= -go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM= -go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU= -go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.3.0/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.3.0/go.mod h1:hO1KLR7jcKaDDKDkvI9dP/FIhpmna5lkqPUQdEjFAM8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.3.0/go.mod h1:keUU7UfnwWTWpJ+FWnyqmogPa82nuU5VUANFq49hlMY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.3.0/go.mod h1:QNX1aly8ehqqX1LEa6YniTU7VY9I6R3X/oPxhGdTceE= -go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= -go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw= -go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc= -go.opentelemetry.io/otel/sdk v1.3.0/go.mod h1:rIo4suHNhQwBIPg9axF8V9CA72Wz2mKF1teNrup8yzs= -go.opentelemetry.io/otel/sdk/export/metric v0.20.0/go.mod h1:h7RBNMsDJ5pmI1zExLi+bJK+Dr8NQCh0qGhm1KDnNlE= -go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4/0TjTXukfxjzSTpHE= -go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= -go.opentelemetry.io/otel/trace v1.3.0/go.mod h1:c/VDhno8888bvQYmbYLqe41/Ldmr/KKunbvWM4/fEjk= -go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M= -go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v0.11.0/go.mod h1:QpEjXPrNQzrFDZgoTo49dgHR9RYRSrg3NAKnUGl9YpQ= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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= -golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= -golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +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= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= -golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 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= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190812073006-9eafafc0a87e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/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-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= -golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +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.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= -golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/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= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190706070813-72ffa07ba3db/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200616133436-c1934b75d054/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= -golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +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.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= 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= -google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= 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/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200117163144-32f20d992d24/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 h1:DdoeryqhaXp1LtT/emMP1BRJPHHKFi5akj/nbx/zNTA= -google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= -google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 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= @@ -1535,117 +490,25 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 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.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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= -gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= -gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= -gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= -gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= -k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ= -k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8= -k8s.io/api v0.22.5/go.mod h1:mEhXyLaSD1qTOf40rRiKXkc+2iCem09rWLlFwhCEiAs= -k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= -k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= -k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc= -k8s.io/apimachinery v0.22.1/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0= -k8s.io/apimachinery v0.22.5/go.mod h1:xziclGKwuuJ2RM5/rSFQSYAj0zdbci3DH8kj+WvyN0U= -k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU= -k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM= -k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q= -k8s.io/apiserver v0.22.5/go.mod h1:s2WbtgZAkTKt679sYtSudEQrTGWUSQAPe6MupLnlmaQ= -k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y= -k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k= -k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0= -k8s.io/client-go v0.22.5/go.mod h1:cs6yf/61q2T1SdQL5Rdcjg9J1ElXSwbjSrW2vFImM4Y= -k8s.io/code-generator v0.19.7/go.mod h1:lwEq3YnLYb/7uVXLorOJfxg+cUu2oihFhHZ0n9NIla0= -k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk= -k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI= -k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM= -k8s.io/component-base v0.22.5/go.mod h1:VK3I+TjuF9eaa+Ln67dKxhGar5ynVbwnGrUiNF4MqCI= -k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM= -k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI= -k8s.io/cri-api v0.20.4/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI= -k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc= -k8s.io/cri-api v0.23.1/go.mod h1:REJE3PSU0h/LOV1APBrupxrEJqnoxZC8KWzkBUHwrK4= -k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= -k8s.io/gengo v0.0.0-20200428234225-8167cfdcfc14/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= -k8s.io/gengo v0.0.0-20201113003025-83324d819ded/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= -k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= -k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= -k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= -k8s.io/klog/v2 v2.9.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= -k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= -k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= -k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= -k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= -k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= -k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= -lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= -sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= -sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= -sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= -sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= -sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +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 v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc= +tags.cncf.io/container-device-interface v0.8.0/go.mod h1:Apb7N4VdILW0EVdEMRYXIDVRZfNJZ+kmEUss2kRRQ6Y= +tags.cncf.io/container-device-interface/specs-go v0.8.0 h1:QYGFzGxvYK/ZLMrjhvY0RjpUavIn4KcmRmVP/JjdBTA= +tags.cncf.io/container-device-interface/specs-go v0.8.0/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws= diff --git a/hack/build-integration-canary.sh b/hack/build-integration-canary.sh new file mode 100755 index 00000000000..0396ada100e --- /dev/null +++ b/hack/build-integration-canary.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash + +# 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. + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD}")" 2>/dev/null 1>&2 && pwd)" +readonly root +# shellcheck source=/dev/null +. "$root/scripts/lib.sh" + +###################### +# Definitions +###################### + +# "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=() + +# List all the repositories we depend on to build and run integration tests +dependencies=( + ktock/buildg + moby/buildkit + containerd/containerd + distribution/distribution + containers/fuse-overlayfs + containerd/fuse-overlayfs-snapshotter + gotestyourself/gotestsum + ipfs/kubo + containerd/nydus-snapshotter + containernetworking/plugins + rootless-containers/rootlesskit + opencontainers/runc + rootless-containers/slirp4netns + awslabs/soci-snapshotter + containerd/stargz-snapshotter + krallin/tini +) + +# Certain dependencies do issue multiple unrelated releaes on their repo - use these below to ignore certain releases +BUILDKIT_EXCLUDE="dockerfile/" +CONTAINERD_EXCLUDE="containerd API" + +# Some dependencies will be checksum-matched. Setting the variables below will trigger us to download and generate shasums +# The value you set the variable to also decides which artifacts you are interested in. +BUILDKIT_CHECKSUM=linux +CNI_PLUGINS_CHECKSUM=linux +CONTAINERD_FUSE_OVERLAYFS_CHECKSUM=linux +FUSE_OVERLAYFS_CHECKSUM=linux +# Avoids the full build +BUILDG_CHECKSUM=buildg-v +ROOTLESSKIT_CHECKSUM=linux +SLIRP4NETNS_CHECKSUM=linux +STARGZ_SNAPSHOTTER_CHECKSUM=linux +# We specifically want the static ones +TINI_CHECKSUM=static + +version::compare(){ + local raw_version_fd="$1" + local parsed + local line + while read -r line; do + parsed+=("$line") + done < <(sed -E 's/^(.* )?v?([0-9]+)[.]([0-9]+)([.]([0-9]+))?(-?([a-z]+)[.]?([0-9]+))?.*/\2\n\3\n\5\n\7\n\8\n/i' < "$raw_version_fd") + + local maj="${higher[0]}" + local min="${higher[1]}" + local patch="${higher[2]}" + local sub="${higher[3]}" + local subv="${higher[4]}" + + log::debug "parsed version: ${parsed[*]}" + log::debug " > current higher version: ${higher[*]}" + + if [ "${parsed[0]}" -gt "$maj" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + elif [ "${parsed[0]}" -lt "$maj" ]; then + return 1 + fi + if [ "${parsed[1]}" -gt "$min" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + elif [ "${parsed[1]}" -lt "$min" ]; then + return 1 + fi + if [ "${parsed[2]}" -gt "$patch" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + elif [ "${parsed[2]}" -lt "$patch" ]; then + return 1 + fi + # If the current latest does not have a sub, then it is more recent + if [ "$sub" == "" ]; then + return 1 + fi + # If it has a sub, and the parsed one does not, then the parsed one is more recent + if [ "${parsed[3]}" == "" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + fi + # Otherwise, we have two subs. Normalize, then compare + # alpha < beta < rc + [ "$sub" == "rc" ] && sub=2 || { [ "$sub" == "beta" ] && sub=1; } || { [ "$sub" == "alpha" ] && sub=0; } || { + log::error "Unrecognized sub pattern: $sub" + exit 42 + } + [ "${parsed[3]}" == "rc" ] && parsed[3]=2 || { [ "${parsed[3]}" == "beta" ] && parsed[3]=1; } || { [ "${parsed[3]}" == "alpha" ] && parsed[3]=0; } || { + log::error "Unrecognized sub pattern: ${parsed[3]}" + exit 42 + } + if [ "${parsed[3]}" -gt "$sub" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + elif [ "${parsed[3]}" -lt "$sub" ]; then + return 1 + fi + # Ok... we are left with just the sub version + if [ "${parsed[4]}" -gt "$subv" ]; then + log::debug " > new higher" + higher=("${parsed[@]}") + return + elif [ "${parsed[4]}" -lt "$subv" ]; then + return 1 + fi +} + +# Retrieves the "highest version" release for a given repo +# Optional argument 2 allows to filter out unwanted release which name matches the argument +# This is useful for repo that do independent releases for assets (like buildkit dockerfiles) +latest::release(){ + local repo="$1" + local ignore="${2:-}" + local line + local name + + higher=(0 0 0 "alpha" 0) + higher_data= + higher_readable= + + log::info "Analyzing releases for $repo" + + while read -r line; do + [ ! "$ignore" ] || ! grep -q "$ignore" <<<"$line" || continue + name="$(echo "$line" | jq -rc .name)" + if [ "$name" == "" ] || [ "$name" == null ] ; then + log::debug " > bogus release name ($name) ignored" + continue + fi + log::debug " > found release: $name" + if version::compare <(echo "$line" | jq -rc .name); then + higher_data="$line" + higher_readable="$(echo "$line" | jq -rc .name | sed -E 's/(.*[ ])?(v?[0-9][0-9.a-z-]+).*/\2/')" + fi + done < <(github::releases "$repo") + + log::info " >>> latest release detected: $higher_readable" +} + +# Retrieve the latest git tag for a given repo +latest::tag(){ + local repo="$1" + + log::info "Analyzing tags for $repo" + github::tags::latest "$repo" +} + +# Once a latest release has been retrieved for a given project, you can get the url to the asset matching OS and ARCH +assets::get(){ + local os="$1" + local arch="$2" + local name= + local found= + + while read -r line; do + name="$(echo "$line" | jq -rc .name)" + log::debug " >>> candidate $name" + ! grep -qi "$os" <<<"$name" || ! grep -qi "$arch" <<<"$name" || ( + ! grep -Eqi "[.]t?g?x?z$" <<<"$name" && grep -Eqi "[.][a-z]+$" <<<"$name" + ) || { + found="$line" + break + } + done < <(echo "$higher_data" | jq -rc .assets.[]) + [ "$found" == "" ] && { + log::warning " >>> no asset found for $os/$arch" + } || { + log::info " >>> found asset for $os/$arch: $(echo "$found" | jq -rc .browser_download_url)" + printf "%s\n" "$(echo "$found" | jq -rc .browser_download_url)" + } +} + +###################### +# Script +###################### + +canary::build::integration(){ + docker_args=(docker build -t test-integration --target test-integration) + + for dep in "${dependencies[@]}"; do + local bl="" + shortname="${dep##*/}" + [ "$shortname" != "plugins" ] || shortname="cni-plugins" + [ "$shortname" != "fuse-overlayfs-snapshotter" ] || shortname="containerd-fuse-overlayfs" + for bl in "${blacklist[@]}"; do + if [ "$bl" == "$shortname" ]; then + log::warning "Dependency $shortname is blacklisted and will be left to its currently pinned version" + break + fi + done + [ "$bl" != "$shortname" ] || continue + + shortsafename="$(printf "%s" "$shortname" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" + + exclusion="${shortsafename}_EXCLUDE" + latest::release "$dep" "${!exclusion:-}" + + # XXX containerd does not display "v" in its released versions + [ "${higher_readable:0:1}" == v ] || higher_readable="v$higher_readable" + + checksum="${shortsafename}_CHECKSUM" + if [ "${!checksum:-}" != "" ]; then + # Checksum file + checksum_file=./Dockerfile.d/SHA256SUMS.d/"${shortname}-${higher_readable}" + if [ ! -e "$checksum_file" ]; then + # Get assets - try first os/arch - fallback on gnu style arch otherwise + assets=() + + # Most well behaved go projects will tag with a go os and arch + candidate="$(assets::get "${!checksum:-}" "amd64")" + # Then non go projects tend to use gnu style + [ "$candidate" != "" ] || candidate="$(assets::get "" "x86_64")" + # And then some projects which are linux only do not specify the OS + [ "$candidate" != "" ] || candidate="$(assets::get "" "amd64")" + [ "$candidate" == "" ] || assets+=("$candidate") + + candidate="$(assets::get "${!checksum:-}" "arm64")" + [ "$candidate" != "" ] || candidate="$(assets::get "" "aarch64")" + [ "$candidate" != "" ] || candidate="$(assets::get "" "arm64")" + [ "$candidate" == "" ] || assets+=("$candidate") + # Fallback to source if there is nothing else + + [ "${#assets[@]}" != 0 ] || candidate="$(assets::get "" "source")" + [ "$candidate" == "" ] || assets+=("$candidate") + + # XXX very special... + if [ "$shortsafename" == "STARGZ_SNAPSHOTTER" ]; then + assets+=("https://raw.githubusercontent.com/containerd/stargz-snapshotter/${higher_readable}/script/config/etc/systemd/system/stargz-snapshotter.service") + fi + + # Write the checksum for what we found + if [ "${#assets[@]}" == 0 ]; then + log::error "No asset found for this checksum-able dependency. Dropping off." + exit 1 + fi + http::checksum "${assets[@]}" > "$checksum_file" + fi + fi + + while read -r line; do + # Extract value after "=" from a possible dockerfile `ARG XXX_VERSION` + old_version=$(echo "$line" | grep "ARG ${shortsafename}_VERSION=") || true + old_version="${old_version##*=}" + [ "$old_version" != "" ] || continue + # If the Dockerfile version does NOT start with a v, adapt to that + [ "${old_version:0:1}" == "v" ] || higher_readable="${higher_readable:1}" + + if [ "$old_version" != "$higher_readable" ]; then + log::warning "Dependency ${shortsafename} is going to use an updated version $higher_readable (currently: $old_version)" + fi + done < ./Dockerfile + + docker_args+=(--build-arg "${shortsafename}_VERSION=$higher_readable") + done + + hub_available_go_version="$(canary::golang::hublatest)" + if [ "$hub_available_go_version" != "" ]; then + docker_args+=(--build-arg "GO_VERSION=$hub_available_go_version") + fi + + log::debug "${docker_args[*]} ." + "${docker_args[@]}" "." +} + +# Hub usually has a delay before available golang version show-up. This method will find the latest available one. +# See +# - https://github.com/containerd/nerdctl/issues/3224 +# - https://github.com/containerd/nerdctl/issues/3306 +canary::golang::hublatest(){ + local hub_tags + local go_version + local available_version="" + local index + + hub_tags="$(http::get /dev/stdout "https://registry-1.docker.io/v2/library/golang/tags/list" -H "Authorization: Bearer $(http::get /dev/stdout "https://auth.docker.io/token?service=registry.docker.io&scope=repository%3Alibrary%2Fgolang%3Apull" | jq -rc .access_token)")" + + index=0 + while [ "$available_version" == "" ] && [ "$index" -lt 5 ]; do + go_version="$(http::get /dev/stdout "https://go.dev/dl/?mode=json&include=all" | jq -rc .[$index].version)" + go_version="${go_version##*go}" + available_version="$(printf "%s" "$hub_tags" | jq -rc ".tags[] | select(.==\"$go_version\")")" + ((index++)) + done || true + + printf "%s" "$available_version" +} + +canary::golang::latest(){ + # Enable extended globbing features to use advanced pattern matching + shopt -s extglob + + # Get latest golang version and split it in components + norm=() + while read -r line; do + line_trimmed="${line//+([[:space:]])/}" + norm+=("$line_trimmed") + done < \ + <(sed -E 's/^go([0-9]+)[.]([0-9]+)([.]([0-9]+))?(([a-z]+)([0-9]+))?/\1.\2\n\4\n\6\n\7/i' \ + <(curl -fsSL "https://go.dev/dl/?mode=json&include=all" | jq -rc .[0].version) \ + ) + + # Serialize version, making sure we have a patch version, and separate possible rcX into .rc-X + [ "${norm[1]}" != "" ] || norm[1]="0" + norm[1]=".${norm[1]}" + [ "${norm[2]}" == "" ] || norm[2]="-${norm[2]}" + [ "${norm[3]}" == "" ] || norm[3]=".${norm[3]}" + # Save it + IFS= + echo "GO_VERSION=${norm[*]}" >> "$GITHUB_ENV" +} diff --git a/hack/build-integration-kubernetes.sh b/hack/build-integration-kubernetes.sh new file mode 100755 index 00000000000..5647e4dd4f8 --- /dev/null +++ b/hack/build-integration-kubernetes.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# 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. + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD}")" 2>/dev/null 1>&2 && pwd)" +readonly root +# shellcheck source=/dev/null +. "$root/scripts/lib.sh" + +GO_VERSION=1.23 +KIND_VERSION=v0.24.0 +CNI_PLUGINS_VERSION=v1.5.1 + +[ "$(uname -m)" == "aarch64" ] && GOARCH=arm64 || GOARCH=amd64 + +_rootful= + +configure::rootful(){ + log::debug "Configuring rootful to: ${1:+true}" + _rootful="${1:+true}" +} + +install::kind(){ + local version="$1" + local temp + temp="$(fs::mktemp "install")" + + http::get "$temp"/kind "https://kind.sigs.k8s.io/dl/$version/kind-linux-${GOARCH:-amd64}" + host::install "$temp"/kind +} + +# shellcheck disable=SC2120 +install::kubectl(){ + local version="${1:-}" + [ "$version" ] || version="$(http::get /dev/stdout https://dl.k8s.io/release/stable.txt)" + local temp + temp="$(fs::mktemp "install")" + + http::get "$temp"/kubectl "https://storage.googleapis.com/kubernetes-release/release/$version/bin/linux/${GOARCH:-amd64}/kubectl" + host::install "$temp"/kubectl +} + +install::cni(){ + local version="$1" + local temp + temp="$(fs::mktemp "install")" + + http::get "$temp"/cni.tgz "https://github.com/containernetworking/plugins/releases/download/$version/cni-plugins-${GOOS:-linux}-${GOARCH:-amd64}-$version.tgz" + sudo mkdir -p /opt/cni/bin + sudo tar xzf "$temp"/cni.tgz -C /opt/cni/bin + mkdir -p ~/opt/cni/bin + tar xzf "$temp"/cni.tgz -C ~/opt/cni/bin + rm "$temp"/cni.tgz +} + +exec::kind(){ + local args=() + [ ! "$_rootful" ] || args=(sudo env PATH="$PATH" KIND_EXPERIMENTAL_PROVIDER="$KIND_EXPERIMENTAL_PROVIDER") + args+=(kind) + + log::debug "${args[*]} $*" + "${args[@]}" "$@" +} + +exec::nerdctl(){ + local args=() + [ ! "$_rootful" ] || args=(sudo env PATH="$PATH") + args+=("$(pwd)"/_output/nerdctl) + + log::debug "${args[*]} $*" + "${args[@]}" "$@" +} + +# Install dependencies +main(){ + log::info "Configuring rootful" + configure::rootful "${ROOTFUL:-}" + + log::info "Installing host dependencies if necessary" + host::require kind 2>/dev/null || install::kind "$KIND_VERSION" + host::require kubectl 2>/dev/null || install::kubectl + + # Build nerdctl to use for kind + make binaries + PATH=$(pwd)/_output:"$PATH" + export PATH + + # Add CNI plugins + install::cni "$CNI_PLUGINS_VERSION" + + # Hack to get go into kind control plane + exec::nerdctl rm -f go-kind 2>/dev/null || true + exec::nerdctl run -d --quiet --name go-kind golang:"$GO_VERSION" sleep Inf + exec::nerdctl cp go-kind:/usr/local/go /tmp/go + exec::nerdctl rm -f go-kind + + # Create fresh cluster + log::info "Creating new cluster" + export KIND_EXPERIMENTAL_PROVIDER=nerdctl + exec::kind delete cluster --name nerdctl-test 2>/dev/null || true + exec::kind create cluster --name nerdctl-test --config=./hack/kind.yaml +} + +main "$@" diff --git a/hack/configure-windows-ci.ps1 b/hack/configure-windows-ci.ps1 index 0d6476e0839..1c97a487182 100644 --- a/hack/configure-windows-ci.ps1 +++ b/hack/configure-windows-ci.ps1 @@ -1,7 +1,6 @@ -$ErrorActionPreference = "Stop" +# To install CNI, see https://github.com/containerd/containerd/blob/release/1.7/script/setup/install-cni-windows -#install build dependencies -choco install --limitoutput --no-progress -y git golang +$ErrorActionPreference = "Stop" #install containerd $version=$env:ctrdVersion @@ -9,46 +8,12 @@ echo "Installing containerd $version" curl.exe -L https://github.com/containerd/containerd/releases/download/v$version/containerd-$version-windows-amd64.tar.gz -o containerd-windows-amd64.tar.gz tar.exe xvf containerd-windows-amd64.tar.gz mkdir -force "$Env:ProgramFiles\containerd" -mv ./bin/* "$Env:ProgramFiles\containerd" +cp ./bin/* "$Env:ProgramFiles\containerd" & $Env:ProgramFiles\containerd\containerd.exe config default | Out-File "$Env:ProgramFiles\containerd\config.toml" -Encoding ascii & $Env:ProgramFiles\containerd\containerd.exe --register-service Start-Service containerd -#configure cni -mkdir -force "$Env:ProgramFiles\containerd\cni\bin" -mkdir -force "$Env:ProgramFiles\containerd\cni\conf" -curl.exe -LO https://github.com/microsoft/windows-container-networking/releases/download/v0.2.0/windows-container-networking-cni-amd64-v0.2.0.zip -Expand-Archive windows-container-networking-cni-amd64-v0.2.0.zip -DestinationPath "$Env:ProgramFiles\containerd\cni\bin" -Force - -curl.exe -LO https://raw.githubusercontent.com/microsoft/SDN/master/Kubernetes/windows/hns.psm1 -ipmo ./hns.psm1 - -# cirrus already has nat net work configured for docker. We can re-use that for testing -$sn=(get-hnsnetwork | ? Name -Like "nat" | select -ExpandProperty subnets) -$subnet=$sn.AddressPrefix -$gateway=$sn.GatewayAddress -@" -{ - "cniVersion": "0.2.0", - "name": "nat", - "type": "nat", - "master": "Ethernet", - "ipam": { - "subnet": "$subnet", - "routes": [ - { - "gateway": "$gateway" - } - ] - }, - "capabilities": { - "portMappings": true, - "dns": true - } -} -"@ | Set-Content "$Env:ProgramFiles\containerd\cni\conf\0-containerd-nat.conf" -Force - echo "configuration complete! Printing configuration..." echo "Service:" get-service containerd diff --git a/hack/generate-release-note.sh b/hack/generate-release-note.sh index 877853566e5..70276919be3 100755 --- a/hack/generate-release-note.sh +++ b/hack/generate-release-note.sh @@ -17,15 +17,15 @@ minimal_amd64tgz="$(find _output -name '*linux-amd64.tar.gz*' -and ! -name '*full*')" full_amd64tgz="$(find _output -name '*linux-amd64.tar.gz*' -and -name '*full*')" -minimal_amd64tgz_basename="$(basename ${minimal_amd64tgz})" -full_amd64tgz_basename="$(basename ${full_amd64tgz})" +minimal_amd64tgz_basename="$(basename "${minimal_amd64tgz}")" +full_amd64tgz_basename="$(basename "${full_amd64tgz}")" cat <<-EOX ## Changes (To be documented) ## Compatible containerd versions -This release of nerdctl is expected to be used with containerd v1.6 or v1.7. +This release of nerdctl is expected to be used with containerd v1.6, v1.7, or v2.0. ## About the binaries - Minimal (\`${minimal_amd64tgz_basename}\`): nerdctl only @@ -37,7 +37,7 @@ Extract the archive to a path like \`/usr/local/bin\` or \`~/bin\` .

\`\`\` -$(tar tzvf ${minimal_amd64tgz}) +$(tar tzvf "${minimal_amd64tgz}") \`\`\`

@@ -49,7 +49,7 @@ Extract the archive to a path like \`/usr/local\` or \`~/.local\` .

\`\`\` -$(tar tzvf ${full_amd64tgz}) +$(tar tzvf "${full_amd64tgz}") \`\`\`

@@ -59,7 +59,7 @@ $(tar tzvf ${full_amd64tgz}) See \`share/doc/nerdctl-full/README.md\`: \`\`\`markdown -$(tar xOzf ${full_amd64tgz} share/doc/nerdctl-full/README.md) +$(tar xOzf "${full_amd64tgz}" share/doc/nerdctl-full/README.md) \`\`\`

diff --git a/hack/kind.yaml b/hack/kind.yaml new file mode 100644 index 00000000000..c6439c02458 --- /dev/null +++ b/hack/kind.yaml @@ -0,0 +1,14 @@ +# https://pkg.go.dev/sigs.k8s.io/kind/pkg/apis/config/v1alpha4#Cluster +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + extraMounts: + - hostPath: _output/nerdctl + containerPath: /usr/local/bin/nerdctl + - hostPath: /tmp/go + containerPath: /usr/local/go + - hostPath: . + containerPath: /nerdctl-source + - hostPath: /opt/cni + containerPath: /opt/cni diff --git a/hack/lint-imports.sh b/hack/lint-imports.sh new file mode 100755 index 00000000000..d37df1a09bf --- /dev/null +++ b/hack/lint-imports.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# 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. + +# FIXME: goimports-reviser is currently broken when it comes to ./... +# Specifically, it will ignore arguments, and will return exit 0 regardless +# This here is a workaround, until they fix it upstream: https://github.com/incu6us/goimports-reviser/pull/157 + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail + +ex=0 + +while read -r file; do + goimports-reviser -list-diff -set-exit-status -output stdout -company-prefixes "github.com/containerd" "$file" || { + ex=$? + >&2 printf "Imports are not listed properly in %s. Consider calling make lint-fix-imports.\n" "$file" + } +done < <(find ./ -type f -name '*.go') + +exit "$ex" diff --git a/hack/scripts/lib.sh b/hack/scripts/lib.sh new file mode 100755 index 00000000000..c29b056ea70 --- /dev/null +++ b/hack/scripts/lib.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash + +# 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. + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail + +## This is a library of generic helpers that can be used across different projects + +# Simple logger +readonly LOG_LEVEL_DEBUG=0 +readonly LOG_LEVEL_INFO=1 +readonly LOG_LEVEL_WARNING=2 +readonly LOG_LEVEL_ERROR=3 + +readonly LOG_COLOR_BLACK=0 +readonly LOG_COLOR_RED=1 +readonly LOG_COLOR_GREEN=2 +readonly LOG_COLOR_YELLOW=3 +readonly LOG_COLOR_BLUE=4 +readonly LOG_COLOR_MAGENTA=5 +readonly LOG_COLOR_CYAN=6 +readonly LOG_COLOR_WHITE=7 +readonly LOG_COLOR_DEFAULT=9 + +readonly LOG_STYLE_DEBUG=( setaf "$LOG_COLOR_WHITE" ) +readonly LOG_STYLE_INFO=( setaf "$LOG_COLOR_GREEN" ) +readonly LOG_STYLE_WARNING=( setaf "$LOG_COLOR_YELLOW" ) +readonly LOG_STYLE_ERROR=( setaf "$LOG_COLOR_RED" ) + +_log::log(){ + local level + local style + local numeric_level + local message="$2" + + level="$(printf "%s" "$1" | tr '[:lower:]' '[:upper:]')" + numeric_level="$(printf "LOG_LEVEL_%s" "$level")" + style="LOG_STYLE_${level}[@]" + + [ "${!numeric_level}" -ge "$LOG_LEVEL" ] || return 0 + + [ ! "$TERM" ] || [ ! -t 2 ] || >&2 tput "${!style}" 2>/dev/null || true + >&2 printf "[%s] %s: %s\n" "$(date 2>/dev/null || true)" "$(printf "%s" "$level" | tr '[:lower:]' '[:upper:]')" "$message" + [ ! "$TERM" ] || [ ! -t 2 ] || >&2 tput op 2>/dev/null || true +} + +log::init(){ + local _ll + # Default log to warning if unspecified + _ll="$(printf "LOG_LEVEL_%s" "${LOG_LEVEL:-warning}" | tr '[:lower:]' '[:upper:]')" + # Default to 3 (warning) if unrecognized + LOG_LEVEL="${!_ll:-3}" +} + +log::debug(){ + _log::log debug "$@" +} + +log::info(){ + _log::log info "$@" +} + +log::warning(){ + _log::log warning "$@" +} + +log::error(){ + _log::log error "$@" +} + +# Helpers +host::require(){ + local binary="$1" + + log::debug "Checking presence of $binary" + command -v "$binary" >/dev/null || { + log::error "You need $binary for this script to work, and it cannot be found in your path" + return 1 + } +} + +host::install(){ + local binary + + for binary in "$@"; do + log::debug "sudo install -D -m 755 $binary /usr/local/bin/$(basename "$binary")" + sudo install -D -m 755 "$binary" /usr/local/bin/"$(basename "$binary")" + done +} + +fs::mktemp(){ + local prefix="${1:-temporary}" + + mktemp -dq "${TMPDIR:-/tmp}/$prefix.XXXXXX" 2>/dev/null || mktemp -dq || { + log::error "Failed to create temporary directory" + return 1 + } +} + +tar::expand(){ + local dir="$1" + local arc="$2" + + log::debug "tar -C $dir -xzf $arc" + tar -C "$dir" -xzf "$arc" +} + +_http::get(){ + local url="$1" + local output="$2" + local retry="$3" + local delay="$4" + local user="${5:-}" + local password="${6:-}" + shift + shift + shift + shift + shift + shift + + local header + local command=(curl -fsSL --retry "$retry" --retry-delay "$delay" -o "$output") + # Add a basic auth user if necessary + [ "$user" == "" ] || command+=(--user "$user:$password") + # Force tls v1.2 and no redirect to http if url scheme is https + [ "${url:0:5}" != "https" ] || command+=(--proto '=https' --tlsv1.2) + # Stuff in any additional arguments as headers + for header in "$@"; do + command+=(-H "$header") + done + # Debug + log::debug "${command[*]} $url" + # Exec + "${command[@]}" "$url" || { + log::error "Failed to connect to $url with $retry retries every $delay seconds" + return 1 + } +} + +http::get(){ + local output="$1" + local url="$2" + shift + shift + + _http::get "$url" "$output" "2" "1" "" "" "$@" +} + +http::healthcheck(){ + local url="$1" + local retry="${2:-5}" + local delay="${3:-1}" + local user="${4:-}" + local password="${5:-}" + shift + shift + shift + shift + shift + + _http::get "$url" /dev/null "$retry" "$delay" "$user" "$password" "$@" +} + +http::checksum(){ + local urls=("$@") + local url + + local temp + temp="$(fs::mktemp "http-checksum")" + + host::require shasum + + for url in "${urls[@]}"; do + http::get "$temp/${url##*/}" "$url" + done + + cd "$temp" + shasum -a 256 ./* + cd - >/dev/null || true +} + +# Github API helpers +# Set GITHUB_TOKEN to use authenticated requests to workaround limitations + +github::settoken(){ + local token="$1" + # If passed token is a github action pattern replace, and we are NOT on github, ignore it + # shellcheck disable=SC2016 + [ "${token:0:3}" == '${{' ] || GITHUB_TOKEN="$token" +} + +github::request(){ + local endpoint="$1" + local args=( + "Accept: application/vnd.github+json" + "X-GitHub-Api-Version: 2022-11-28" + ) + + [ "${GITHUB_TOKEN:-}" == "" ] || args+=("Authorization: Bearer $GITHUB_TOKEN") + + http::get /dev/stdout https://api.github.com/"$endpoint" "${args[@]}" +} + +github::tags::latest(){ + local repo="$1" + github::request "repos/$repo/tags" | jq -rc .[0].name +} + +github::releases(){ + local repo="$1" + github::request "repos/$repo/releases" | + jq -rc .[] +} + +github::releases::latest(){ + local repo="$1" + github::request "repos/$repo/releases/latest" | jq -rc . +} + +log::init +host::require jq +host::require tar +host::require curl diff --git a/hack/test-integration.sh b/hack/test-integration.sh new file mode 100755 index 00000000000..73e2b4ebb19 --- /dev/null +++ b/hack/test-integration.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +# 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. + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD}")" 2>/dev/null 1>&2 && pwd)" +readonly root + +readonly timeout="60m" +readonly retries="2" +readonly needsudo="${WITH_SUDO:-}" + +# See https://github.com/containerd/nerdctl/blob/main/docs/testing/README.md#about-parallelization +args=(--format=testname --jsonfile /tmp/test-integration.log --packages="$root"/../cmd/nerdctl/...) + +if [ "$#" == 0 ]; then + "$root"/test-integration.sh -test.only-flaky=false + "$root"/test-integration.sh -test.only-flaky=true + exit +fi + +for arg in "$@"; do + if [ "$arg" == "-test.only-flaky=true" ] || [ "$arg" == "-test.only-flaky" ]; then + args+=("--rerun-fails=$retries") + break + fi +done + +if [ "$needsudo" == "true" ] || [ "$needsudo" == "yes" ] || [ "$needsudo" == "1" ]; then + gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -exec sudo -args -test.allow-kill-daemon "$@" +else + gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -args -test.allow-kill-daemon "$@" +fi + +echo "These are the tests that took more than 10 seconds:" +gotestsum tool slowest --threshold 10s --jsonfile /tmp/test-integration.log diff --git a/hack/verify-pkg-isolation.sh b/hack/verify-pkg-isolation.sh index e6b2150608a..3f1edd22e30 100755 --- a/hack/verify-pkg-isolation.sh +++ b/hack/verify-pkg-isolation.sh @@ -17,7 +17,7 @@ echo "Verifying that ./pkg/... is decoupled from the CLI packages" set -eux -o pipefail -if go list -f '{{join .Deps "\n"}}' ./pkg/... | grep -E '^(github.com/spf13/cobra|github.com/spf13/pflag|github.com/containerd/nerdctl/cmd)'; then +if go list -f '{{join .Deps "\n"}}' ./pkg/... | grep -E '^(github.com/spf13/cobra|github.com/spf13/pflag|github.com/containerd/nerdctl/v2/cmd)'; then echo >&2 "ERROR: ./pkg/... is not decoupled from the CLI packages" exit 1 fi diff --git a/pkg/annotations/annotations.go b/pkg/annotations/annotations.go new file mode 100644 index 00000000000..97c1edd56ef --- /dev/null +++ b/pkg/annotations/annotations.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 annotations defines OCI annotations +package annotations + +const ( + // Prefix is the common prefix of nerdctl annotations + Prefix = "nerdctl/" + + // Bypass4netns is the flag for acceleration with bypass4netns + // Boolean value which can be parsed with strconv.ParseBool() is required. + // (like "nerdctl/bypass4netns=true" or "nerdctl/bypass4netns=false") + Bypass4netns = Prefix + "bypass4netns" + + // Bypass4netnsIgnoreSubnets is a JSON of []string that is appended to + // the `bypass4netns --ignore` list. + Bypass4netnsIgnoreSubnets = Bypass4netns + "-ignore-subnets" + + // Bypass4netnsIgnoreBind disables acceleration for bind. + // Boolean value which can be parsed with strconv.ParseBool() is required. + Bypass4netnsIgnoreBind = Bypass4netns + "-ignore-bind" +) + +var ShellCompletions = []string{ + Bypass4netns + "=true", + Bypass4netns + "=false", + Bypass4netnsIgnoreSubnets + "=", + Bypass4netnsIgnoreBind + "=true", + Bypass4netnsIgnoreBind + "=false", +} diff --git a/pkg/api/types/builder_types.go b/pkg/api/types/builder_types.go index 76e8934089e..b9574aebcc6 100644 --- a/pkg/api/types/builder_types.go +++ b/pkg/api/types/builder_types.go @@ -43,6 +43,10 @@ type BuilderBuildOptions struct { Progress string // Secret file to expose to the build: id=mysecret,src=/local/secret Secret []string + // Allow extra privileged entitlement, e.g. network.host, security.insecure + Allow []string + // Attestation parameters (format: "type=sbom,generator=image")" + Attest []string // SSH agent socket or keys to expose to the build (format: default|[=|[,]]) SSH []string // Quiet suppress the build output and print image ID on success @@ -61,6 +65,14 @@ type BuilderBuildOptions struct { Label []string // BuildContext is the build context BuildContext string + // ExtendedBuildContext is a pair of key=value (e.g. myorg/myapp=docker-image://path/to/image, dir2=/path/to/dir2) + ExtendedBuildContext []string + // NetworkMode mode for the build context + NetworkMode string + // Pull determines if we should try to pull latest image from remote. Default is buildkit's default. + Pull *bool + // ExtraHosts is a set of custom host-to-IP mappings. + ExtraHosts []string } // BuilderPruneOptions specifies options for `nerdctl builder prune`. @@ -72,4 +84,6 @@ type BuilderPruneOptions struct { BuildKitHost string // All will remove all unused images and all build cache, not just dangling ones All bool + // Force will not prompt for confirmation. + Force bool } diff --git a/pkg/api/types/container_network_types.go b/pkg/api/types/container_network_types.go index c4476662b9d..c4b5971240c 100644 --- a/pkg/api/types/container_network_types.go +++ b/pkg/api/types/container_network_types.go @@ -17,7 +17,7 @@ package types import ( - gocni "github.com/containerd/go-cni" + "github.com/containerd/go-cni" ) // NetworkOptions struct defining networking-related options. @@ -28,6 +28,8 @@ type NetworkOptions struct { MACAddress string // IPAddress set specific static IP address(es) to use IPAddress string + // IP6Address set specific static IP6 address(es) to use + IP6Address string // Hostname set container host name Hostname string // DNSServers set custom DNS servers @@ -41,5 +43,5 @@ type NetworkOptions struct { // UTS namespace to use UTSNamespace string // PortMappings specifies a list of ports to publish from the container to the host - PortMappings []gocni.PortMapping + PortMappings []cni.PortMapping } diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go index a107f16cac1..13e798cecad 100644 --- a/pkg/api/types/container_types.go +++ b/pkg/api/types/container_types.go @@ -62,10 +62,14 @@ type ContainerCreateOptions struct { Interactive bool // TTY specifies whether to allocate a pseudo-TTY for the container TTY bool + // SigProxy specifies whether to proxy all received signals to the process + SigProxy bool // Detach runs container in background and print container ID Detach bool // The key sequence for detaching a container. DetachKeys string + // Attach STDIN, STDOUT, or STDERR + Attach []string // Restart specifies the policy to apply when a container exits Restart string // Rm specifies whether to remove the container automatically when it exits @@ -169,6 +173,8 @@ type ContainerCreateOptions struct { CapDrop []string // Privileged gives extended privileges to this container Privileged bool + // Systemd + Systemd string // #endregion // #region for runtime flags @@ -185,6 +191,8 @@ type ContainerCreateOptions struct { Tmpfs []string // Mount specifies a list of mounts to mount Mount []string + // VolumesFrom specifies a list of specified containers to mount from + VolumesFrom []string // #endregion // #region for rootfs flags @@ -213,9 +221,12 @@ type ContainerCreateOptions struct { // Name assign a name to the container Name string // Label set meta data on a container + // (not passed through to the OCI runtime since nerdctl v2.0, with an exception for "nerdctl/bypass4netns") Label []string // LabelFile read in a line delimited file of labels LabelFile []string + // Annotations set meta data on a container (passed through to the OCI runtime) + Annotations []string // CidFile write the container ID to the file CidFile string // PidFile specifies the file path to write the task's pid. The CLI syntax conforms to Podman convention. @@ -323,6 +334,8 @@ type ContainerInspectOptions struct { GOptions GlobalCommandOptions // Format of the output Format string + // Whether to report the size + Size bool // Inspect mode, either dockercompat or native Mode string } @@ -342,6 +355,13 @@ type ContainerCommitOptions struct { Pause bool } +// ContainerDiffOptions specifies options for `nerdctl (container) diff`. +type ContainerDiffOptions struct { + Stdout io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions +} + // ContainerLogsOptions specifies options for `nerdctl (container) logs`. type ContainerLogsOptions struct { Stdout io.Writer @@ -368,6 +388,18 @@ type ContainerWaitOptions struct { GOptions GlobalCommandOptions } +// ContainerAttachOptions specifies options for `nerdctl (container) attach`. +type ContainerAttachOptions struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + + // GOptions is the global options. + GOptions GlobalCommandOptions + // DetachKeys is the key sequences to detach from the container. + DetachKeys string +} + // ContainerExecOptions specifies options for `nerdctl (container) exec` type ContainerExecOptions struct { GOptions GlobalCommandOptions @@ -391,7 +423,6 @@ type ContainerExecOptions struct { // ContainerListOptions specifies options for `nerdctl (container) list`. type ContainerListOptions struct { - Stdout io.Writer // GOptions is the global options. GOptions GlobalCommandOptions // Show all containers (default shows just running). @@ -401,21 +432,19 @@ type ContainerListOptions struct { LastN int // Truncate output (e.g., container ID, command of the container main process, etc.) or not. Truncate bool - // Only display container IDs. - Quiet bool // Display total file sizes. Size bool - // Format the output using the given Go template (e.g., '{{json .}}', 'table', 'wide'). - Format string // Filters matches containers based on given conditions. Filters []string } // ContainerCpOptions specifies options for `nerdctl (container) cp` type ContainerCpOptions struct { + // GOptions is the global options. + GOptions GlobalCommandOptions + // ContainerReq is name, short ID, or long ID of container to copy to/from. + ContainerReq string Container2Host bool - // Process id - Pid int // Destination path to copy file to. DestPath string // Source path to copy file from. @@ -423,3 +452,19 @@ type ContainerCpOptions struct { // Follow symbolic links in SRC_PATH FollowSymLink bool } + +// ContainerStatsOptions specifies options for `nerdctl stats`. +type ContainerStatsOptions struct { + Stdout io.Writer + Stderr io.Writer + // GOptions is the global options. + GOptions GlobalCommandOptions + // Show all containers (default shows just running). + All bool + // Pretty-print images using a Go template, e.g., {{json .}}. + Format string + // Disable streaming stats and only pull the first result. + NoStream bool + // Do not truncate output. + NoTrunc bool +} diff --git a/pkg/api/types/global.go b/pkg/api/types/global.go index bffb23eee26..132fb17e137 100644 --- a/pkg/api/types/global.go +++ b/pkg/api/types/global.go @@ -16,6 +16,6 @@ package types -import "github.com/containerd/nerdctl/pkg/config" +import "github.com/containerd/nerdctl/v2/pkg/config" type GlobalCommandOptions config.Config diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go index 51fd3bc4bff..0a723ca2cd2 100644 --- a/pkg/api/types/image_types.go +++ b/pkg/api/types/image_types.go @@ -16,7 +16,11 @@ package types -import "io" +import ( + "io" + + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) // ImageListOptions specifies options for `nerdctl image list`. type ImageListOptions struct { @@ -80,6 +84,13 @@ type ImageConvertOptions struct { EstargzKeepDiffID bool // #endregion + // #region zstd flags + // 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 // ZstdChunked convert legacy tar(.gz) layers to zstd:chunked for lazy pulling. Should be used in conjunction with '--oci' ZstdChunked bool @@ -152,6 +163,7 @@ type ImagePushOptions struct { Stdout io.Writer GOptions GlobalCommandOptions SignOptions ImageSignOptions + SociOptions SociOptions // Platforms convert content for a specific platform Platforms []string // AllPlatforms convert content for all platforms @@ -169,22 +181,34 @@ type ImagePushOptions struct { AllowNondistributableArtifacts bool } +// RemoteSnapshotterFlags are used for pulling with remote snapshotters +// e.g. SOCI, stargz, overlaybd +type RemoteSnapshotterFlags struct { + SociIndexDigest string +} + // ImagePullOptions specifies options for `nerdctl (image) pull`. type ImagePullOptions struct { - Stdout io.Writer - Stderr io.Writer + Stdout io.Writer + Stderr io.Writer + // ProgressOutputToStdout directs progress output to stdout instead of stderr + ProgressOutputToStdout bool + GOptions GlobalCommandOptions VerifyOptions ImageVerifyOptions - // Unpack the image for the current single platform (auto/true/false) - Unpack string - // Pull content for a specific platform - Platform []string - // Pull content for all platforms - AllPlatforms bool + // Unpack the image for the current single platform. + // 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 + // Pull mode + Mode string // Suppress verbose output Quiet bool // multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs) IPFSAddress string + // Flags to pass into remote snapshotters + RFlags RemoteSnapshotterFlags } // ImageTagOptions specifies options for `nerdctl (image) tag`. @@ -215,6 +239,8 @@ type ImagePruneOptions struct { GOptions GlobalCommandOptions // All Remove all unused images, not just dangling ones. All bool + // Filters output based on conditions provided for the --filter argument + Filters []string // Force will not prompt for confirmation. Force bool } @@ -256,3 +282,11 @@ type ImageVerifyOptions struct { // CosignCertificateOidcIssuerRegexp A regular expression alternative to --certificate-oidc-issuer for --verify=cosign. Accepts the Go regular expression syntax described at https://golang.org/s/re2syntax. Either --cosign-certificate-oidc-issuer or --cosign-certificate-oidc-issuer-regexp must be set for keyless flows CosignCertificateOidcIssuerRegexp string } + +// SociOptions contains options for SOCI. +type SociOptions struct { + // Span size that soci index uses to segment layer data. Default is 4 MiB. + 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 +} diff --git a/pkg/api/types/load_types.go b/pkg/api/types/load_types.go index df115234cdd..d3ceaf350b1 100644 --- a/pkg/api/types/load_types.go +++ b/pkg/api/types/load_types.go @@ -29,4 +29,6 @@ type ImageLoadOptions struct { Platform []string // AllPlatforms import content for all platforms AllPlatforms bool + // Quiet suppresses the load output. + Quiet bool } diff --git a/pkg/api/types/network_types.go b/pkg/api/types/network_types.go index 4a65ff2122d..5cb26b3ea15 100644 --- a/pkg/api/types/network_types.go +++ b/pkg/api/types/network_types.go @@ -18,16 +18,23 @@ package types import ( "io" - - "github.com/containerd/nerdctl/pkg/netutil" ) // NetworkCreateOptions specifies options for `nerdctl network create`. type NetworkCreateOptions struct { // GOptions is the global options GOptions GlobalCommandOptions - // CreateOptions is the option for creating network - CreateOptions netutil.CreateOptions + + Name string + Driver string + Options map[string]string + IPAMDriver string + IPAMOptions map[string]string + Subnets []string + Gateway string + IPRange string + Labels []string + IPv6 bool } // NetworkInspectOptions specifies options for `nerdctl network inspect`. @@ -52,6 +59,8 @@ type NetworkListOptions struct { Quiet bool // Format the output using the given Go template, e.g, '{{json .}}', 'wide' Format string + // Filter matches network based on given conditions + Filters []string } // NetworkPruneOptions specifies options for `nerdctl network prune`. diff --git a/pkg/api/types/system_types.go b/pkg/api/types/system_types.go index 65df3218674..bfadba7a057 100644 --- a/pkg/api/types/system_types.go +++ b/pkg/api/types/system_types.go @@ -37,6 +37,8 @@ type SystemEventsOptions struct { GOptions GlobalCommandOptions // Format the output using the given Go template, e.g, '{{json .}} Format string + // Filter events based on given conditions + Filters []string } // SystemPruneOptions specifies options for `nerdctl system prune`. diff --git a/pkg/api/types/volume_types.go b/pkg/api/types/volume_types.go index 8fd67cecb09..903d8e68698 100644 --- a/pkg/api/types/volume_types.go +++ b/pkg/api/types/volume_types.go @@ -54,6 +54,8 @@ type VolumeListOptions struct { type VolumePruneOptions struct { Stdout io.Writer GOptions GlobalCommandOptions + //Remove all unused volumes, not just anonymous ones + All bool // Do not prompt for confirmation Force bool } diff --git a/pkg/apparmorutil/apparmorutil_linux.go b/pkg/apparmorutil/apparmorutil_linux.go index 901940180d0..600047a54da 100644 --- a/pkg/apparmorutil/apparmorutil_linux.go +++ b/pkg/apparmorutil/apparmorutil_linux.go @@ -23,9 +23,10 @@ import ( "strings" "sync" - "github.com/containerd/containerd/pkg/apparmor" - "github.com/containerd/containerd/pkg/userns" - "github.com/sirupsen/logrus" + "github.com/moby/sys/userns" + + "github.com/containerd/containerd/v2/pkg/apparmor" + "github.com/containerd/log" ) // CanLoadNewProfile returns whether the current process can load a new AppArmor profile. @@ -73,7 +74,7 @@ func CanApplySpecificExistingProfile(profileName string) bool { cmd := exec.Command("aa-exec", "-p", profileName, "--", "true") out, err := cmd.CombinedOutput() if err != nil { - logrus.WithError(err).Debugf("failed to run %v: %q", cmd.Args, string(out)) + log.L.WithError(err).Debugf("failed to run %v: %q", cmd.Args, string(out)) return false } return true @@ -101,7 +102,7 @@ func Profiles() ([]Profile, error) { namePath := filepath.Join(profilesPath, ent.Name(), "name") b, err := os.ReadFile(namePath) if err != nil { - logrus.WithError(err).Warnf("failed to read %q", namePath) + log.L.WithError(err).Warnf("failed to read %q", namePath) continue } profile := Profile{ @@ -110,7 +111,7 @@ func Profiles() ([]Profile, error) { modePath := filepath.Join(profilesPath, ent.Name(), "mode") b, err = os.ReadFile(modePath) if err != nil { - logrus.WithError(err).Warnf("failed to read %q", namePath) + log.L.WithError(err).Warnf("failed to read %q", namePath) } else { profile.Mode = strings.TrimSpace(string(b)) } diff --git a/pkg/buildkitutil/buildkitutil.go b/pkg/buildkitutil/buildkitutil.go index 7a5ac2fd02f..5b9570a1ddb 100644 --- a/pkg/buildkitutil/buildkitutil.go +++ b/pkg/buildkitutil/buildkitutil.go @@ -34,10 +34,12 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" + "strings" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/hashicorp/go-multierror" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) const ( @@ -57,37 +59,24 @@ func BuildctlBaseArgs(buildkitHost string) []string { } func GetBuildkitHost(namespace string) (string, error) { - if namespace == "" { - return "", fmt.Errorf("namespace must be specified") - } - // Try candidate locations of the current containerd namespace. - run := "/run/" - if rootlessutil.IsRootless() { - var err error - run, err = rootlessutil.XDGRuntimeDir() - if err != nil { - logrus.Warn(err) - run = fmt.Sprintf("/run/user/%d", rootlessutil.ParentEUID()) - } - } - var hostRel []string - if namespace != "default" { - hostRel = append(hostRel, fmt.Sprintf("buildkit-%s/buildkitd.sock", namespace)) + paths, err := getBuildkitHostCandidates(namespace) + if err != nil { + return "", err } - hostRel = append(hostRel, "buildkit-default/buildkitd.sock", "buildkit/buildkitd.sock") - var allErr error - for _, p := range hostRel { - logrus.Debugf("Choosing the buildkit host %q, candidates=%v (in %q)", p, hostRel, run) - buildkitHost := "unix://" + filepath.Join(run, p) + + var errs []error //nolint:prealloc + for _, buildkitHost := range paths { + log.L.Debugf("Choosing the buildkit host %q, candidates=%v", buildkitHost, paths) _, err := pingBKDaemon(buildkitHost) if err == nil { - logrus.Debugf("Chosen buildkit host %q", buildkitHost) + log.L.Debugf("Chosen buildkit host %q", buildkitHost) return buildkitHost, nil } - allErr = multierror.Append(allErr, fmt.Errorf("failed to ping to host %s: %w", buildkitHost, err)) + errs = append(errs, fmt.Errorf("failed to ping to host %s: %w", buildkitHost, err)) } - logrus.WithError(allErr).Error(getHint()) - return "", fmt.Errorf("no buildkit host is available, tried %d candidates: %w", len(hostRel), allErr) + allErr := errors.Join(errs...) + log.L.WithError(allErr).Error(getHint()) + return "", fmt.Errorf("no buildkit host is available, tried %d candidates: %w", len(paths), allErr) } func GetWorkerLabels(buildkitHost string) (labels map[string]string, _ error) { @@ -136,7 +125,7 @@ func getHint() string { func PingBKDaemon(buildkitHost string) error { if out, err := pingBKDaemon(buildkitHost); err != nil { if out != "" { - logrus.Error(out) + log.L.Error(out) } return fmt.Errorf(getHint()+": %w", err) } @@ -144,8 +133,9 @@ func PingBKDaemon(buildkitHost string) error { } func pingBKDaemon(buildkitHost string) (output string, _ error) { - if runtime.GOOS != "linux" { - return "", errors.New("only linux is supported") + supportedOses := []string{"linux", "freebsd", "windows"} + if !slices.Contains(supportedOses, runtime.GOOS) { + return "", fmt.Errorf("only %s are supported", strings.Join(supportedOses, ", ")) } buildctlBinary, err := BuildctlBinary() if err != nil { @@ -185,7 +175,7 @@ func WriteTempDockerfile(rc io.Reader) (dockerfileDir string, err error) { return dockerfileDir, nil } -// Buildkit file returns the values for the following buildctl args +// BuildKitFile returns the values for the following buildctl args // --localfilename=dockerfile={absDir} // --opt=filename={file} func BuildKitFile(dir, inputfile string) (absDir string, file string, err error) { @@ -215,7 +205,7 @@ func BuildKitFile(dir, inputfile string) (absDir string, file string, err error) return "", "", err } if !bytes.Equal(dockerfile, containerfile) { - logrus.Warnf("%s and %s have different contents, building with %s", DefaultDockerfileName, ContainerfileName, DefaultDockerfileName) + log.L.Warnf("%s and %s have different contents, building with %s", DefaultDockerfileName, ContainerfileName, DefaultDockerfileName) } } if dErr != nil { diff --git a/pkg/buildkitutil/buildkitutil_freebsd.go b/pkg/buildkitutil/buildkitutil_freebsd.go new file mode 100644 index 00000000000..adaec5e42ef --- /dev/null +++ b/pkg/buildkitutil/buildkitutil_freebsd.go @@ -0,0 +1,22 @@ +/* + 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 buildkitutil + +func getRuntimeVariableDataDir() string { + // Per hier(7) dated July 6, 2023. + return "/var/run" +} diff --git a/cmd/nerdctl/volume_remove_linux_test.go b/pkg/buildkitutil/buildkitutil_linux.go similarity index 55% rename from cmd/nerdctl/volume_remove_linux_test.go rename to pkg/buildkitutil/buildkitutil_linux.go index b244a5101e2..dad13d022f2 100644 --- a/cmd/nerdctl/volume_remove_linux_test.go +++ b/pkg/buildkitutil/buildkitutil_linux.go @@ -14,25 +14,27 @@ limitations under the License. */ -package main +package buildkitutil import ( "fmt" - "testing" - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestVolumeRemove(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) + "github.com/containerd/log" - base.Cmd("volume", "create", tID).AssertOK() - base.Cmd("run", "-v", fmt.Sprintf("%s:/volume", tID), "--name", tID, testutil.CommonImage).AssertOK() - defer base.Cmd("rm", "-f", tID).Run() + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) - base.Cmd("volume", "rm", tID).AssertFail() - base.Cmd("rm", "-f", tID).AssertOK() - base.Cmd("volume", "rm", tID).AssertOK() +func getRuntimeVariableDataDir() string { + // Per Linux Foundation "Filesystem Hierarchy Standard" version 3.0 section 3.15. + // Under version 2.3, this was "/var/run". + run := "/run" + if rootlessutil.IsRootless() { + var err error + run, err = rootlessutil.XDGRuntimeDir() + if err != nil { + log.L.Warn(err) + run = fmt.Sprintf("/run/user/%d", rootlessutil.ParentEUID()) + } + } + return run } diff --git a/pkg/buildkitutil/buildkitutil_unix.go b/pkg/buildkitutil/buildkitutil_unix.go new file mode 100644 index 00000000000..5c5498d3aa9 --- /dev/null +++ b/pkg/buildkitutil/buildkitutil_unix.go @@ -0,0 +1,39 @@ +//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 buildkitutil + +import ( + "fmt" + "path/filepath" +) + +func getBuildkitHostCandidates(namespace string) ([]string, error) { + if namespace == "" { + return []string{}, fmt.Errorf("namespace must be specified") + } + // Try candidate locations of the current containerd namespace. + run := getRuntimeVariableDataDir() + var candidates []string + if namespace != "default" { + candidates = append(candidates, "unix://"+filepath.Join(run, fmt.Sprintf("buildkit-%s/buildkitd.sock", namespace))) + } + candidates = append(candidates, "unix://"+filepath.Join(run, "buildkit-default/buildkitd.sock"), "unix://"+filepath.Join(run, "buildkit/buildkitd.sock")) + + return candidates, nil +} diff --git a/pkg/buildkitutil/buildkitutil_windows.go b/pkg/buildkitutil/buildkitutil_windows.go new file mode 100644 index 00000000000..dd38470c068 --- /dev/null +++ b/pkg/buildkitutil/buildkitutil_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 buildkitutil + +func getBuildkitHostCandidates(namespace string) ([]string, error) { + return []string{"npipe:////./pipe/buildkitd"}, nil +} diff --git a/pkg/bypass4netnsutil/bypass.go b/pkg/bypass4netnsutil/bypass.go index b5497d3bba8..bc9eed11f9d 100644 --- a/pkg/bypass4netnsutil/bypass.go +++ b/pkg/bypass4netnsutil/bypass.go @@ -18,34 +18,55 @@ package bypass4netnsutil import ( "context" + "encoding/json" "fmt" "net" "path/filepath" - "github.com/containerd/containerd/errdefs" - gocni "github.com/containerd/go-cni" b4nnapi "github.com/rootless-containers/bypass4netns/pkg/api" "github.com/rootless-containers/bypass4netns/pkg/api/daemon/client" - rlkclient "github.com/rootless-containers/rootlesskit/pkg/api/client" + rlkclient "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" + + "github.com/containerd/errdefs" + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/annotations" ) -func NewBypass4netnsCNIBypassManager(client client.Client, rlkClient rlkclient.Client) (*Bypass4netnsCNIBypassManager, error) { +func NewBypass4netnsCNIBypassManager(client client.Client, rlkClient rlkclient.Client, annotationsMap map[string]string) (*Bypass4netnsCNIBypassManager, error) { if client == nil || rlkClient == nil { return nil, errdefs.ErrInvalidArgument } + enabled, bindEnabled, err := IsBypass4netnsEnabled(annotationsMap) + if err != nil { + return nil, err + } + if !enabled { + return nil, errdefs.ErrInvalidArgument + } + var ignoreSubnets []string + if v := annotationsMap[annotations.Bypass4netnsIgnoreSubnets]; v != "" { + if err := json.Unmarshal([]byte(v), &ignoreSubnets); err != nil { + return nil, fmt.Errorf("failed to unmarshal annotation %q: %q: %w", annotations.Bypass4netnsIgnoreSubnets, v, err) + } + } pm := &Bypass4netnsCNIBypassManager{ - Client: client, - rlkClient: rlkClient, + Client: client, + rlkClient: rlkClient, + ignoreSubnets: ignoreSubnets, + ignoreBind: !bindEnabled, } return pm, nil } type Bypass4netnsCNIBypassManager struct { client.Client - rlkClient rlkclient.Client + rlkClient rlkclient.Client + ignoreSubnets []string + ignoreBind bool } -func (b4nnm *Bypass4netnsCNIBypassManager) StartBypass(ctx context.Context, ports []gocni.PortMapping, id, stateDir string) error { +func (b4nnm *Bypass4netnsCNIBypassManager) StartBypass(ctx context.Context, ports []cni.PortMapping, id, stateDir string) error { socketPath, err := GetSocketPathByID(id) if err != nil { return err @@ -73,18 +94,21 @@ func (b4nnm *Bypass4netnsCNIBypassManager) StartBypass(ctx context.Context, port PidFilePath: pidFilePath, LogFilePath: logFilePath, // "auto" can detect CNI CIDRs automatically - IgnoreSubnets: []string{"127.0.0.0/8", rlkCIDR, "auto"}, + IgnoreSubnets: append([]string{"127.0.0.0/8", rlkCIDR, "auto"}, b4nnm.ignoreSubnets...), + IgnoreBind: b4nnm.ignoreBind, } - portMap := []b4nnapi.PortSpec{} - for _, p := range ports { - portMap = append(portMap, b4nnapi.PortSpec{ - ParentIP: p.HostIP, - ParentPort: int(p.HostPort), - ChildPort: int(p.ContainerPort), - Protos: []string{p.Protocol}, - }) + if !b4nnm.ignoreBind { + portMap := []b4nnapi.PortSpec{} + for _, p := range ports { + portMap = append(portMap, b4nnapi.PortSpec{ + ParentIP: p.HostIP, + ParentPort: int(p.HostPort), + ChildPort: int(p.ContainerPort), + Protos: []string{p.Protocol}, + }) + } + spec.PortMapping = portMap } - spec.PortMapping = portMap _, err = b4nnm.BypassManager().StartBypass(ctx, spec) if err != nil { return err diff --git a/pkg/bypass4netnsutil/bypass4netnsutil.go b/pkg/bypass4netnsutil/bypass4netnsutil.go index 4e2dfa826e3..b34f1e4c08f 100644 --- a/pkg/bypass4netnsutil/bypass4netnsutil.go +++ b/pkg/bypass4netnsutil/bypass4netnsutil.go @@ -23,11 +23,13 @@ import ( "path/filepath" "strconv" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/labels" "github.com/opencontainers/runtime-spec/specs-go" b4nnoci "github.com/rootless-containers/bypass4netns/pkg/oci" + + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/annotations" ) func generateSecurityOpt(listenerPath string) (oci.SpecOpts, error) { @@ -46,8 +48,8 @@ func generateSecurityOpt(listenerPath string) (oci.SpecOpts, error) { return opt, nil } -func GenerateBypass4netnsOpts(securityOptsMaps map[string]string, labelMaps map[string]string, id string) ([]oci.SpecOpts, error) { - b4nn, ok := labelMaps[labels.Bypass4netns] +func GenerateBypass4netnsOpts(securityOptsMaps map[string]string, annotationsMap map[string]string, id string) ([]oci.SpecOpts, error) { + b4nn, ok := annotationsMap[annotations.Bypass4netns] if !ok { return nil, nil } @@ -129,19 +131,31 @@ func GetPidFilePathByID(id string) (string, error) { return "", err } - socketPath := filepath.Join(xdgRuntimeDir, "bypass4netns", id[0:15]+".pid") - return socketPath, nil + pidPath := filepath.Join(xdgRuntimeDir, "bypass4netns", id[0:15]+".pid") + + err = os.MkdirAll(filepath.Join(xdgRuntimeDir, "bypass4netns"), 0o700) + if err != nil { + return "", err + } + + return pidPath, nil } -func IsBypass4netnsEnabled(annotations map[string]string) (bool, error) { - if b4nn, ok := annotations[labels.Bypass4netns]; ok { - b4nnEnable, err := strconv.ParseBool(b4nn) +func IsBypass4netnsEnabled(annotationsMap map[string]string) (enabled, bindEnabled bool, err error) { + if b4nn, ok := annotationsMap[annotations.Bypass4netns]; ok { + enabled, err = strconv.ParseBool(b4nn) if err != nil { - return false, err + return + } + bindEnabled = enabled + if s, ok := annotationsMap[annotations.Bypass4netnsIgnoreBind]; ok { + var bindDisabled bool + bindDisabled, err = strconv.ParseBool(s) + if err != nil { + return + } + bindEnabled = !bindDisabled } - - return b4nnEnable, nil } - - return false, nil + return } diff --git a/pkg/cioutil/container_io.go b/pkg/cioutil/container_io.go new file mode 100644 index 00000000000..1b0b1a01bfb --- /dev/null +++ b/pkg/cioutil/container_io.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 cioutil + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "runtime" + "sync" + "syscall" + "time" + + "github.com/containerd/containerd/v2/cmd/containerd-shim-runc-v2/process" + "github.com/containerd/containerd/v2/defaults" + "github.com/containerd/containerd/v2/pkg/cio" +) + +const binaryIOProcTermTimeout = 12 * time.Second // Give logger process 10 seconds for cleanup + +// ncio is a basic container IO implementation. +type ncio struct { + cmd *exec.Cmd + config cio.Config + wg *sync.WaitGroup + closers []io.Closer + cancel context.CancelFunc +} + +var bufPool = sync.Pool{ + New: func() interface{} { + buffer := make([]byte, 32<<10) + return &buffer + }, +} + +func (c *ncio) Config() cio.Config { + return c.config +} + +func (c *ncio) Wait() { + if c.wg != nil { + c.wg.Wait() + } +} + +func (c *ncio) Close() error { + + var lastErr error + + if c.cmd != nil && c.cmd.Process != nil { + + // Send SIGTERM first, so logger process has a chance to flush and exit properly + if err := c.cmd.Process.Signal(syscall.SIGTERM); err != nil { + lastErr = fmt.Errorf("failed to send SIGTERM: %w", err) + + if err := c.cmd.Process.Kill(); err != nil { + lastErr = errors.Join(lastErr, fmt.Errorf("failed to kill process after faulty SIGTERM: %w", err)) + } + + } + + done := make(chan error, 1) + go func() { + done <- c.cmd.Wait() + }() + + select { + case err := <-done: + return err + case <-time.After(binaryIOProcTermTimeout): + + err := c.cmd.Process.Kill() + if err != nil { + lastErr = fmt.Errorf("failed to kill shim logger process: %w", err) + } + + } + } + + for _, closer := range c.closers { + if closer == nil { + continue + } + if err := closer.Close(); err != nil { + lastErr = err + } + } + return lastErr +} + +func (c *ncio) Cancel() { + if c.cancel != nil { + c.cancel() + } +} + +func NewContainerIO(namespace string, logURI string, tty bool, stdin io.Reader, stdout, stderr io.Writer) cio.Creator { + return func(id string) (_ cio.IO, err error) { + var ( + cmd *exec.Cmd + closers []func() error + streams = &cio.Streams{ + Terminal: tty, + } + ) + + defer func() { + if err == nil { + return + } + result := []error{err} + for _, fn := range closers { + result = append(result, fn()) + } + err = errors.Join(result...) + }() + + if stdin != nil { + streams.Stdin = stdin + } + + var stdoutWriters []io.Writer + if stdout != nil { + stdoutWriters = append(stdoutWriters, stdout) + } + + var stderrWriters []io.Writer + if stderr != nil { + stderrWriters = append(stderrWriters, stderr) + } + + if runtime.GOOS != "windows" { + // starting logging binary logic is from https://github.com/containerd/containerd/blob/194a1fdd2cde35bc019ef138f30485e27fe0913e/cmd/containerd-shim-runc-v2/process/io.go#L247 + stdoutr, stdoutw, err := os.Pipe() + if err != nil { + return nil, err + } + closers = append(closers, stdoutr.Close, stdoutw.Close) + + stderrr, stderrw, err := os.Pipe() + if err != nil { + return nil, err + } + closers = append(closers, stderrr.Close, stderrw.Close) + + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + closers = append(closers, r.Close, w.Close) + + u, err := url.Parse(logURI) + if err != nil { + return nil, err + } + cmd = process.NewBinaryCmd(u, id, namespace) + cmd.ExtraFiles = append(cmd.ExtraFiles, stdoutr, stderrr, w) + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start binary process with cmdArgs %v: %w", cmd.Args, err) + } + + closers = append(closers, func() error { return cmd.Process.Kill() }) + + // close our side of the pipe after start + if err := w.Close(); err != nil { + return nil, fmt.Errorf("failed to close write pipe after start: %w", err) + } + + // wait for the logging binary to be ready + b := make([]byte, 1) + if _, err := r.Read(b); err != nil && err != io.EOF { + return nil, fmt.Errorf("failed to read from logging binary: %w", err) + } + + stdoutWriters = append(stdoutWriters, stdoutw) + stderrWriters = append(stderrWriters, stderrw) + } + + streams.Stdout = io.MultiWriter(stdoutWriters...) + streams.Stderr = io.MultiWriter(stderrWriters...) + + if streams.FIFODir == "" { + streams.FIFODir = defaults.DefaultFIFODir + } + fifos, err := cio.NewFIFOSetInDir(streams.FIFODir, id, streams.Terminal) + if err != nil { + return nil, err + } + + if streams.Stdin == nil { + fifos.Stdin = "" + } + if streams.Stdout == nil { + fifos.Stdout = "" + } + if streams.Stderr == nil { + fifos.Stderr = "" + } + return copyIO(cmd, fifos, streams) + } +} diff --git a/pkg/cioutil/container_io_unix.go b/pkg/cioutil/container_io_unix.go new file mode 100644 index 00000000000..c9d23110940 --- /dev/null +++ b/pkg/cioutil/container_io_unix.go @@ -0,0 +1,136 @@ +//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 cioutil + +import ( + "context" + "fmt" + "io" + "os/exec" + "sync" + "syscall" + + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/fifo" +) + +type pipes struct { + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser +} + +func (p *pipes) closers() []io.Closer { + return []io.Closer{p.Stdin, p.Stdout, p.Stderr} +} + +// copyIO is from https://github.com/containerd/containerd/blob/148d21b1ae0718b75718a09ecb307bb874270f59/cio/io_unix.go#L55 +func copyIO(cmd *exec.Cmd, fifos *cio.FIFOSet, ioset *cio.Streams) (*ncio, error) { + var ctx, cancel = context.WithCancel(context.Background()) + pipes, err := openFifos(ctx, fifos) + if err != nil { + cancel() + return nil, err + } + + if fifos.Stdin != "" { + go func() { + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(pipes.Stdin, ioset.Stdin, *p) + pipes.Stdin.Close() + }() + } + + var wg = &sync.WaitGroup{} + if fifos.Stdout != "" { + wg.Add(1) + go func() { + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(ioset.Stdout, pipes.Stdout, *p) + pipes.Stdout.Close() + wg.Done() + }() + } + + if !fifos.Terminal && fifos.Stderr != "" { + wg.Add(1) + go func() { + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(ioset.Stderr, pipes.Stderr, *p) + pipes.Stderr.Close() + wg.Done() + }() + } + + return &ncio{ + cmd: cmd, + config: fifos.Config, + wg: wg, + closers: append(pipes.closers(), fifos), + cancel: func() { + cancel() + for _, c := range pipes.closers() { + if c != nil { + c.Close() + } + } + }, + }, nil +} + +func openFifos(ctx context.Context, fifos *cio.FIFOSet) (f pipes, retErr error) { + defer func() { + if retErr != nil { + fifos.Close() + } + }() + + if fifos.Stdin != "" { + if f.Stdin, retErr = fifo.OpenFifo(ctx, fifos.Stdin, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_NONBLOCK, 0700); retErr != nil { + return f, fmt.Errorf("failed to open stdin fifo: %w", retErr) + } + defer func() { + if retErr != nil && f.Stdin != nil { + f.Stdin.Close() + } + }() + } + if fifos.Stdout != "" { + if f.Stdout, retErr = fifo.OpenFifo(ctx, fifos.Stdout, syscall.O_RDONLY|syscall.O_CREAT|syscall.O_NONBLOCK, 0700); retErr != nil { + return f, fmt.Errorf("failed to open stdout fifo: %w", retErr) + } + defer func() { + if retErr != nil && f.Stdout != nil { + f.Stdout.Close() + } + }() + } + if !fifos.Terminal && fifos.Stderr != "" { + if f.Stderr, retErr = fifo.OpenFifo(ctx, fifos.Stderr, syscall.O_RDONLY|syscall.O_CREAT|syscall.O_NONBLOCK, 0700); retErr != nil { + return f, fmt.Errorf("failed to open stderr fifo: %w", retErr) + } + } + return f, nil +} diff --git a/pkg/cioutil/container_io_windows.go b/pkg/cioutil/container_io_windows.go new file mode 100644 index 00000000000..4d9789e0763 --- /dev/null +++ b/pkg/cioutil/container_io_windows.go @@ -0,0 +1,110 @@ +/* + 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 cioutil + +import ( + "fmt" + "io" + "os/exec" + + "github.com/Microsoft/go-winio" + + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/log" +) + +// copyIO is from https://github.com/containerd/containerd/blob/148d21b1ae0718b75718a09ecb307bb874270f59/cio/io_windows.go#L44 +func copyIO(_ *exec.Cmd, fifos *cio.FIFOSet, ioset *cio.Streams) (_ *ncio, retErr error) { + ncios := &ncio{cmd: nil, config: fifos.Config} + + defer func() { + if retErr != nil { + _ = ncios.Close() + } + }() + + if fifos.Stdin != "" { + l, err := winio.ListenPipe(fifos.Stdin, nil) + if err != nil { + return nil, fmt.Errorf("failed to create stdin pipe %s: %w", fifos.Stdin, err) + } + ncios.closers = append(ncios.closers, l) + + go func() { + c, err := l.Accept() + if err != nil { + log.L.WithError(err).Errorf("failed to accept stdin connection on %s", fifos.Stdin) + return + } + + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(c, ioset.Stdin, *p) + c.Close() + l.Close() + }() + } + + if fifos.Stdout != "" { + l, err := winio.ListenPipe(fifos.Stdout, nil) + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe %s: %w", fifos.Stdout, err) + } + ncios.closers = append(ncios.closers, l) + + go func() { + c, err := l.Accept() + if err != nil { + log.L.WithError(err).Errorf("failed to accept stdout connection on %s", fifos.Stdout) + return + } + + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(ioset.Stdout, c, *p) + c.Close() + l.Close() + }() + } + + if fifos.Stderr != "" { + l, err := winio.ListenPipe(fifos.Stderr, nil) + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe %s: %w", fifos.Stderr, err) + } + ncios.closers = append(ncios.closers, l) + + go func() { + c, err := l.Accept() + if err != nil { + log.L.WithError(err).Errorf("failed to accept stderr connection on %s", fifos.Stderr) + return + } + + p := bufPool.Get().(*[]byte) + defer bufPool.Put(p) + + io.CopyBuffer(ioset.Stderr, c, *p) + c.Close() + l.Close() + }() + } + + return ncios, nil +} diff --git a/pkg/clientutil/client.go b/pkg/clientutil/client.go index 45013133ad5..7304f7a44ec 100644 --- a/pkg/clientutil/client.go +++ b/pkg/clientutil/client.go @@ -24,17 +24,18 @@ import ( "runtime" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/namespaces" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/systemutil" "github.com/opencontainers/go-digest" - "github.com/sirupsen/logrus" -) -func NewClient(ctx context.Context, namespace, address string, opts ...containerd.ClientOpt) (*containerd.Client, context.Context, context.CancelFunc, error) { + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/systemutil" +) +func NewClient(ctx context.Context, namespace, address string, opts ...containerd.Opt) (*containerd.Client, context.Context, context.CancelFunc, error) { ctx = namespaces.WithNamespace(ctx, namespace) address = strings.TrimPrefix(address, "unix://") @@ -56,15 +57,15 @@ func NewClient(ctx context.Context, namespace, address string, opts ...container return client, ctx, cancel, nil } -func NewClientWithPlatform(ctx context.Context, namespace, address, platform string, clientOpts ...containerd.ClientOpt) (*containerd.Client, context.Context, context.CancelFunc, error) { +func NewClientWithPlatform(ctx context.Context, namespace, address, platform string, clientOpts ...containerd.Opt) (*containerd.Client, context.Context, context.CancelFunc, error) { if platform != "" { if canExec, canExecErr := platformutil.CanExecProbably(platform); !canExec { warn := fmt.Sprintf("Platform %q seems incompatible with the host platform %q. If you see \"exec format error\", see https://github.com/containerd/nerdctl/blob/main/docs/multi-platform.md", platform, platforms.DefaultString()) if canExecErr != nil { - logrus.WithError(canExecErr).Warn(warn) + log.L.WithError(canExecErr).Warn(warn) } else { - logrus.Warn(warn) + log.L.Warn(warn) } } platformParsed, err := platforms.Parse(platform) diff --git a/pkg/cmd/apparmor/inspect_linux.go b/pkg/cmd/apparmor/inspect_linux.go index 5fa2a17286d..67b067b532f 100644 --- a/pkg/cmd/apparmor/inspect_linux.go +++ b/pkg/cmd/apparmor/inspect_linux.go @@ -19,9 +19,10 @@ package apparmor import ( "fmt" - "github.com/containerd/containerd/contrib/apparmor" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/defaults" + "github.com/containerd/containerd/v2/contrib/apparmor" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func Inspect(options types.ApparmorInspectOptions) error { diff --git a/pkg/cmd/apparmor/list_linux.go b/pkg/cmd/apparmor/list_linux.go index c7a03b3798b..b5825545159 100644 --- a/pkg/cmd/apparmor/list_linux.go +++ b/pkg/cmd/apparmor/list_linux.go @@ -23,9 +23,9 @@ import ( "text/tabwriter" "text/template" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/containerd/nerdctl/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" ) func List(options types.ApparmorListOptions) error { @@ -63,7 +63,7 @@ func List(options types.ApparmorListOptions) error { if err := tmpl.Execute(&b, f); err != nil { return err } - if _, err = fmt.Fprintf(w, b.String()+"\n"); err != nil { + if _, err = fmt.Fprintln(w, b.String()); err != nil { return err } } else if quiet { diff --git a/pkg/cmd/apparmor/load_linux.go b/pkg/cmd/apparmor/load_linux.go index b8112aa99e0..f5930379a6c 100644 --- a/pkg/cmd/apparmor/load_linux.go +++ b/pkg/cmd/apparmor/load_linux.go @@ -17,12 +17,13 @@ package apparmor import ( - "github.com/containerd/containerd/contrib/apparmor" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/contrib/apparmor" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func Load() error { - logrus.Infof("Loading profile %q", defaults.AppArmorProfileName) + log.L.Infof("Loading profile %q", defaults.AppArmorProfileName) return apparmor.LoadDefaultProfile(defaults.AppArmorProfileName) } diff --git a/pkg/cmd/apparmor/unload_linux.go b/pkg/cmd/apparmor/unload_linux.go index 8994834efdf..f9ab7779f0d 100644 --- a/pkg/cmd/apparmor/unload_linux.go +++ b/pkg/cmd/apparmor/unload_linux.go @@ -17,11 +17,12 @@ package apparmor import ( - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" ) func Unload(target string) error { - logrus.Infof("Unloading profile %q", target) + log.L.Infof("Unloading profile %q", target) return apparmorutil.Unload(target) } diff --git a/pkg/cmd/builder/build.go b/pkg/cmd/builder/build.go index 84c5ff43b28..2ab6df0e8cb 100644 --- a/pkg/cmd/builder/build.go +++ b/pkg/cmd/builder/build.go @@ -28,20 +28,39 @@ import ( "strconv" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/images/archive" - "github.com/containerd/containerd/platforms" - dockerreference "github.com/containerd/containerd/reference/docker" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/sirupsen/logrus" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + 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/errdefs" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "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/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) +type PlatformParser interface { + Parse(platform string) (platforms.Platform, error) + DefaultSpec() platforms.Platform +} + +type platformParser struct{} + +func (p platformParser) Parse(platform string) (platforms.Platform, error) { + return platforms.Parse(platform) +} + +func (p platformParser) DefaultSpec() platforms.Platform { + return platforms.DefaultSpec() +} + func Build(ctx context.Context, client *containerd.Client, options types.BuilderBuildOptions) error { buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, err := generateBuildctlArgs(ctx, client, options) if err != nil { @@ -51,7 +70,7 @@ func Build(ctx context.Context, client *containerd.Client, options types.Builder defer cleanup() } - logrus.Debugf("running %s %v", buildctlBinary, buildctlArgs) + log.L.Debugf("running %s %v", buildctlBinary, buildctlArgs) buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...) buildctlCmd.Env = os.Environ() @@ -91,13 +110,13 @@ func Build(ctx context.Context, client *containerd.Client, options types.Builder if err != nil { return err } - if err := os.WriteFile(options.IidFile, []byte(id), 0600); err != nil { + if err := os.WriteFile(options.IidFile, []byte(id), 0644); err != nil { return err } } if len(tags) > 1 { - logrus.Debug("Found more than 1 tag") + log.L.Debug("Found more than 1 tag") imageService := client.ImageService() image, err := imageService.Get(ctx, tags[0]) if err != nil { @@ -108,6 +127,12 @@ func Build(ctx context.Context, client *containerd.Client, options types.Builder if _, err := imageService.Create(ctx, image); err != nil { // if already exists; skip. if errors.Is(err, errdefs.ErrAlreadyExists) { + if err = imageService.Delete(ctx, targetRef); err != nil { + return err + } + if _, err = imageService.Create(ctx, image); err != nil { + return err + } continue } return fmt.Errorf("unable to tag image: %s", err) @@ -131,8 +156,10 @@ func loadImage(ctx context.Context, in io.Reader, namespace, address, snapshotte if err != nil { return err } - defer cancel() - + defer func() { + cancel() + client.Close() + }() 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(platMC)) if err != nil { @@ -202,24 +229,26 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option output = fmt.Sprintf("type=local,dest=%s", output) } if strings.Contains(output, "type=docker") || strings.Contains(output, "type=oci") { - needsLoading = true + if !strings.Contains(output, "dest=") { + needsLoading = true + } } } if tags = strutil.DedupeStrSlice(options.Tag); len(tags) > 0 { ref := tags[0] - named, err := dockerreference.ParseNormalizedNamed(ref) + parsedReference, err := referenceutil.Parse(ref) if err != nil { return "", nil, false, "", nil, nil, err } - output += ",name=" + dockerreference.TagNameOnly(named).String() + output += ",name=" + parsedReference.String() // pick the first tag and add it to output for idx, tag := range tags { - named, err := dockerreference.ParseNormalizedNamed(tag) + parsedReference, err = referenceutil.Parse(tag) if err != nil { return "", nil, false, "", nil, nil, err } - tags[idx] = dockerreference.TagNameOnly(named).String() + tags[idx] = parsedReference.String() } } else if len(tags) == 0 { output = output + ",dangling-name-prefix=" @@ -261,6 +290,38 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option return "", nil, false, "", nil, nil, err } + buildCtx, err := parseContextNames(options.ExtendedBuildContext) + if err != nil { + return "", nil, false, "", nil, nil, err + } + + for k, v := range buildCtx { + isURL := strings.HasPrefix(v, "https://") || strings.HasPrefix(v, "http://") + isDockerImage := strings.HasPrefix(v, "docker-image://") || strings.HasPrefix(v, "target:") + + if isURL || isDockerImage { + buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=context:%s=%s", k, v)) + continue + } + + if isOCILayout := strings.HasPrefix(v, "oci-layout://"); isOCILayout { + args, err := parseBuildContextFromOCILayout(k, v) + if err != nil { + return "", nil, false, "", nil, nil, err + } + + buildctlArgs = append(buildctlArgs, args...) + continue + } + + path, err := filepath.Abs(v) + if err != nil { + return "", nil, false, "", nil, nil, err + } + buildctlArgs = append(buildctlArgs, fmt.Sprintf("--local=%s=%s", k, path)) + buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=context:%s=local:%s", k, k)) + } + buildctlArgs = append(buildctlArgs, "--local=dockerfile="+dir) buildctlArgs = append(buildctlArgs, "--opt=filename="+file) @@ -283,7 +344,7 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option if ok { buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=build-arg:%s=%s", ba, val)) } else { - logrus.Debugf("ignoring unset build arg %q", ba) + log.L.Debugf("ignoring unset build arg %q", ba) } } else if len(arr) > 1 && len(arr[0]) > 0 { buildctlArgs = append(buildctlArgs, "--opt=build-arg:"+ba) @@ -298,7 +359,7 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option buildctlArgs = append(buildctlArgs, "--export-cache=type=inline") } } else { - logrus.WithError(err).Warnf("invalid BUILDKIT_INLINE_CACHE: %q", bic) + log.L.WithError(err).Warnf("invalid BUILDKIT_INLINE_CACHE: %q", bic) } } } else { @@ -322,10 +383,33 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option buildctlArgs = append(buildctlArgs, "--no-cache") } + if options.Pull != nil { + switch *options.Pull { + case true: + buildctlArgs = append(buildctlArgs, "--opt=image-resolve-mode=pull") + case false: + buildctlArgs = append(buildctlArgs, "--opt=image-resolve-mode=local") + } + } + for _, s := range strutil.DedupeStrSlice(options.Secret) { buildctlArgs = append(buildctlArgs, "--secret="+s) } + for _, s := range strutil.DedupeStrSlice(options.Allow) { + buildctlArgs = append(buildctlArgs, "--allow="+s) + } + + for _, s := range strutil.DedupeStrSlice(options.Attest) { + optAttestType, optAttestAttrs, _ := strings.Cut(s, ",") + if strings.HasPrefix(optAttestType, "type=") { + optAttestType := strings.TrimPrefix(optAttestType, "type=") + buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=attest:%s=%s", optAttestType, optAttestAttrs)) + } else { + return "", nil, false, "", nil, nil, fmt.Errorf("attestation type not specified") + } + } + for _, s := range strutil.DedupeStrSlice(options.SSH) { buildctlArgs = append(buildctlArgs, "--ssh="+s) } @@ -345,7 +429,7 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option } if !options.Rm { - logrus.Warn("ignoring deprecated flag: '--rm=false'") + log.L.Warn("ignoring deprecated flag: '--rm=false'") } if options.IidFile != "" { @@ -358,6 +442,26 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option buildctlArgs = append(buildctlArgs, "--metadata-file="+metaFile) } + if options.NetworkMode != "" { + switch options.NetworkMode { + case "none": + buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode) + case "host": + buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode, "--allow=network.host", "--allow=security.insecure") + case "", "default": + default: + log.L.Debugf("ignoring network build arg %s", options.NetworkMode) + } + } + + if len(options.ExtraHosts) > 0 { + extraHosts, err := containerutil.ParseExtraHosts(options.ExtraHosts, options.GOptions.HostGatewayIP, "=") + if err != nil { + return "", nil, false, "", nil, nil, err + } + buildctlArgs = append(buildctlArgs, "--opt=add-hosts="+strings.Join(extraHosts, ",")) + } + return buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, nil } @@ -370,7 +474,7 @@ func getDigestFromMetaFile(path string) (string, error) { metadata := map[string]json.RawMessage{} if err := json.Unmarshal(data, &metadata); err != nil { - logrus.WithError(err).Errorf("failed to unmarshal metadata file %s", path) + log.L.WithError(err).Errorf("failed to unmarshal metadata file %s", path) return "", err } digestRaw, ok := metadata["containerimage.digest"] @@ -379,18 +483,41 @@ func getDigestFromMetaFile(path string) (string, error) { } var digest string if err := json.Unmarshal(digestRaw, &digest); err != nil { - logrus.WithError(err).Errorf("failed to unmarshal digset") + log.L.WithError(err).Errorf("failed to unmarshal digset") return "", err } return digest, nil } +func isMatchingRuntimePlatform(platform string, parser PlatformParser) bool { + p, err := parser.Parse(platform) + if err != nil { + return false + } + d := parser.DefaultSpec() + + if p.OS == d.OS && p.Architecture == d.Architecture && (p.Variant == "" || p.Variant == d.Variant) { + return true + } + + return false +} + +func isBuildPlatformDefault(platform []string, parser PlatformParser) bool { + if len(platform) == 0 { + return true + } else if len(platform) == 1 { + return isMatchingRuntimePlatform(platform[0], parser) + } + return false +} + func isImageSharable(buildkitHost, namespace, uuid, snapshotter string, platform []string) (bool, error) { labels, err := buildkitutil.GetWorkerLabels(buildkitHost) if err != nil { return false, err } - logrus.Debugf("worker labels: %+v", labels) + log.L.Debugf("worker labels: %+v", labels) executor, ok := labels["org.mobyproject.buildkit.worker.executor"] if !ok { return false, nil @@ -411,5 +538,79 @@ func isImageSharable(buildkitHost, namespace, uuid, snapshotter string, platform // Dockerfile doesn't contain instructions require base images like RUN) even if `--output type=image,unpack=true` // is passed to BuildKit. Thus, we need to use `type=docker` or `type=oci` when nerdctl builds non-default platform // image using `platform` option. - return executor == "containerd" && containerdUUID == uuid && containerdNamespace == namespace && workerSnapshotter == snapshotter && len(platform) == 0, nil + parser := new(platformParser) + return executor == "containerd" && containerdUUID == uuid && containerdNamespace == namespace && workerSnapshotter == snapshotter && isBuildPlatformDefault(platform, parser), nil +} + +func parseContextNames(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + result := make(map[string]string, len(values)) + for _, value := range values { + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid context value: %s, expected key=value", value) + } + result[kv[0]] = kv[1] + } + return result, nil +} + +var ( + ErrOCILayoutPrefixNotFound = errors.New("OCI layout prefix not found") + ErrOCILayoutEmptyDigest = errors.New("OCI layout cannot have empty digest") +) + +func parseBuildContextFromOCILayout(name, path string) ([]string, error) { + path, found := strings.CutPrefix(path, "oci-layout://") + if !found { + return []string{}, ErrOCILayoutPrefixNotFound + } + + abspath, err := filepath.Abs(path) + if err != nil { + return []string{}, err + } + + ociIndex, err := readOCIIndexFromPath(abspath) + if err != nil { + return []string{}, err + } + + var digest string + for _, manifest := range ociIndex.Manifests { + if images.IsManifestType(manifest.MediaType) { + digest = manifest.Digest.String() + } + } + + if digest == "" { + return []string{}, ErrOCILayoutEmptyDigest + } + + return []string{ + fmt.Sprintf("--oci-layout=parent-image-key=%s", abspath), + fmt.Sprintf("--opt=context:%s=oci-layout:parent-image-key@%s", name, digest), + }, nil +} + +func readOCIIndexFromPath(path string) (*ocispec.Index, error) { + ociIndexJSONFile, err := os.Open(filepath.Join(path, "index.json")) + if err != nil { + return nil, err + } + defer ociIndexJSONFile.Close() + + rawBytes, err := io.ReadAll(ociIndexJSONFile) + if err != nil { + return nil, err + } + + var ociIndex *ocispec.Index + err = json.Unmarshal(rawBytes, &ociIndex) + if err != nil { + return nil, err + } + return ociIndex, nil } diff --git a/pkg/cmd/builder/build_test.go b/pkg/cmd/builder/build_test.go new file mode 100644 index 00000000000..081d899d7f3 --- /dev/null +++ b/pkg/cmd/builder/build_test.go @@ -0,0 +1,240 @@ +/* + 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 builder + +import ( + "fmt" + "path/filepath" + "reflect" + "runtime" + "testing" + + specs "github.com/opencontainers/image-spec/specs-go/v1" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" +) + +type MockParse struct { + ctrl *gomock.Controller + recorder *MockParseRecorder +} + +type MockParseRecorder struct { + mock *MockParse +} + +func newMockParser(ctrl *gomock.Controller) *MockParse { + mock := &MockParse{ctrl: ctrl} + mock.recorder = &MockParseRecorder{mock} + return mock +} + +func (m *MockParse) EXPECT() *MockParseRecorder { + return m.recorder +} + +func (m *MockParse) Parse(platform string) (specs.Platform, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Parse") + ret0, _ := ret[0].(specs.Platform) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +func (m *MockParseRecorder) Parse(platform string) *gomock.Call { + m.mock.ctrl.T.Helper() + return m.mock.ctrl.RecordCallWithMethodType(m.mock, "Parse", reflect.TypeOf((*MockParse)(nil).Parse)) +} + +func (m *MockParse) DefaultSpec() specs.Platform { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DefaultSpec") + ret0, _ := ret[0].(specs.Platform) + return ret0 +} + +func (m *MockParseRecorder) DefaultSpec() *gomock.Call { + m.mock.ctrl.T.Helper() + return m.mock.ctrl.RecordCallWithMethodType(m.mock, "DefaultSpec", reflect.TypeOf((*MockParse)(nil).DefaultSpec)) +} + +func TestIsMatchingRuntimePlatform(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mock func(*MockParse) + want bool + }{ + { + name: "Image is shareable when Runtime and build platform match for os, arch and variant", + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: true, + }, + { + name: "Image is shareable when Runtime and build platform match for os, arch. Variant is not defined", + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: ""}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: true, + }, + { + name: "Image is not shareable when Runtime and build platform donot math OS", + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "OS", Architecture: "mockArch", Variant: ""}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: false, + }, + { + name: "Image is not shareable when Runtime and build platform donot math Arch", + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "mockOS", Architecture: "Arch", Variant: ""}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: false, + }, + { + name: "Image is not shareable when Runtime and build platform donot math Variant", + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "Variant"}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockParser := newMockParser(ctrl) + tc.mock(mockParser) + r := isMatchingRuntimePlatform("test", mockParser) + assert.Equal(t, r, tc.want, tc.name) + }) + } +} + +func TestIsBuildPlatformDefault(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mock func(*MockParse) + platform []string + want bool + }{ + { + name: "Image is shreable when len of platform is 0", + platform: make([]string, 0), + want: true, + }, + { + name: "Image is shareable when Runtime and build platform match for os, arch and variant", + platform: []string{"test"}, + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: true, + }, + { + name: "Image is not shareable when Runtime build platform dont match", + platform: []string{"test"}, + mock: func(mockParser *MockParse) { + mockParser.EXPECT().Parse("test").Return(specs.Platform{OS: "OS", Architecture: "mockArch", Variant: "mockVariant"}, nil) + mockParser.EXPECT().DefaultSpec().Return(specs.Platform{OS: "mockOS", Architecture: "mockArch", Variant: "mockVariant"}) + }, + want: false, + }, + { + name: "Image is not shareable when more than 2 platforms are passed", + platform: []string{"test1", "test2"}, + want: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockParser := newMockParser(ctrl) + if len(tc.platform) == 1 { + tc.mock(mockParser) + } + r := isBuildPlatformDefault(tc.platform, mockParser) + assert.Equal(t, r, tc.want, tc.name) + }) + } +} + +func TestParseBuildctlArgsForOCILayout(t *testing.T) { + tests := []struct { + name string + ociLayoutName string + ociLayoutPath string + expectedArgs []string + errorIsNil bool + expectedErr string + }{ + { + name: "PrefixNotFoundError", + ociLayoutName: "unit-test", + ociLayoutPath: "/tmp/oci-layout/", + expectedArgs: []string{}, + expectedErr: ErrOCILayoutPrefixNotFound.Error(), + }, + { + name: "DirectoryNotFoundError", + ociLayoutName: "unit-test", + ociLayoutPath: "oci-layout:///tmp/oci-layout", + expectedArgs: []string{}, + expectedErr: "open /tmp/oci-layout/index.json: no such file or directory", + }, + } + + if runtime.GOOS == "windows" { + abspath, err := filepath.Abs("/tmp/oci-layout") + assert.NilError(t, err) + tests[1].expectedErr = fmt.Sprintf( + "open %s\\index.json: The system cannot find the path specified.", + abspath, + ) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args, err := parseBuildContextFromOCILayout(test.ociLayoutName, test.ociLayoutPath) + if test.errorIsNil { + assert.NilError(t, err) + } else { + assert.Error(t, err, test.expectedErr) + } + assert.Equal(t, len(args), len(test.expectedArgs)) + assert.DeepEqual(t, args, test.expectedArgs) + }) + } +} diff --git a/pkg/cmd/builder/prune.go b/pkg/cmd/builder/prune.go index 18ba6dc0b49..541507f6cd0 100644 --- a/pkg/cmd/builder/prune.go +++ b/pkg/cmd/builder/prune.go @@ -23,9 +23,10 @@ import ( "io" "os/exec" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" ) // Prune will prune all build cache. @@ -40,7 +41,7 @@ func Prune(ctx context.Context, options types.BuilderPruneOptions) ([]buildkitut buildctlArgs = append(buildctlArgs, "--all") } buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...) - logrus.Debugf("running %v", buildctlCmd.Args) + log.G(ctx).Debugf("running %v", buildctlCmd.Args) buildctlCmd.Stderr = options.Stderr stdout, err := buildctlCmd.StdoutPipe() if err != nil { diff --git a/pkg/cmd/compose/compose.go b/pkg/cmd/compose/compose.go index 1b5cd8d37c0..ba6e0868af1 100644 --- a/pkg/cmd/compose/compose.go +++ b/pkg/cmd/compose/compose.go @@ -23,25 +23,34 @@ import ( "os" "path/filepath" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/volume" - "github.com/containerd/nerdctl/pkg/composer" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/ipfs" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/containerd/nerdctl/pkg/signutil" - "github.com/containerd/nerdctl/pkg/strutil" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "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/imgutil" + "github.com/containerd/nerdctl/v2/pkg/ipfs" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/signutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) +//nolint:unused +var locked *os.File + // New returns a new *composer.Composer. func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, options composer.Options, stdout, stderr io.Writer) (*composer.Composer, error) { - cniEnv, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath, netutil.WithDefaultNetwork()) + if err := composer.Lock(globalOptions.DataRoot, globalOptions.Address); err != nil { + return nil, err + } + + cniEnv, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath, netutil.WithNamespace(globalOptions.Namespace), netutil.WithDefaultNetwork(globalOptions.BridgeIP)) if err != nil { return nil, err } @@ -75,22 +84,15 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op if err != nil { return nil, err } - options.VolumeExists = func(volName string) (bool, error) { - if _, volGetErr := volStore.Get(volName, false); volGetErr == nil { - return true, nil - } else if errors.Is(volGetErr, errdefs.ErrNotFound) { - return false, nil - } else { - return false, volGetErr - } - } + // FIXME: this is racy. See note in up_volume.go + options.VolumeExists = volStore.Exists options.ImageExists = func(ctx context.Context, rawRef string) (bool, error) { - refNamed, err := referenceutil.ParseAny(rawRef) + parsedReference, err := referenceutil.Parse(rawRef) if err != nil { return false, err } - ref := refNamed.String() + ref := parsedReference.String() if _, err := client.ImageService().Get(ctx, ref); err != nil { if errors.Is(err, errdefs.ErrNotFound) { return false, nil @@ -110,8 +112,23 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op ocispecPlatforms = []ocispec.Platform{parsed} // no append } - // IPFS reference - if scheme, ref, err := referenceutil.ParseIPFSRefWithScheme(imageName); err == nil { + imgPullOpts := types.ImagePullOptions{ + GOptions: globalOptions, + OCISpecPlatform: ocispecPlatforms, + Unpack: nil, + Mode: pullMode, + Quiet: quiet, + RFlags: types.RemoteSnapshotterFlags{}, + Stdout: stdout, + Stderr: stderr, + } + + parsedReference, err := referenceutil.Parse(imageName) + if err != nil { + return err + } + + if parsedReference.Protocol != "" { var ipfsPath string if ipfsAddress := options.IPFSAddress; ipfsAddress != "" { dir, err := os.MkdirTemp("", "apidirtmp") @@ -124,8 +141,7 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op } ipfsPath = dir } - _, err = ipfs.EnsureImage(ctx, client, stdout, stderr, globalOptions.Snapshotter, scheme, ref, - pullMode, ocispecPlatforms, nil, quiet, ipfsPath) + _, err = ipfs.EnsureImage(ctx, client, string(parsedReference.Protocol), parsedReference.String(), ipfsPath, imgPullOpts) return err } @@ -135,8 +151,7 @@ func New(client *containerd.Client, globalOptions types.GlobalCommandOptions, op return err } - _, err = imgutil.EnsureImage(ctx, client, stdout, stderr, globalOptions.Snapshotter, ref, - pullMode, globalOptions.InsecureRegistry, globalOptions.HostsDir, ocispecPlatforms, nil, quiet) + _, err = imgutil.EnsureImage(ctx, client, ref, imgPullOpts) return err } diff --git a/pkg/cmd/container/attach.go b/pkg/cmd/container/attach.go new file mode 100644 index 00000000000..177a9fd03db --- /dev/null +++ b/pkg/cmd/container/attach.go @@ -0,0 +1,167 @@ +/* + 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" + "errors" + "fmt" + + "github.com/containerd/console" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/consoleutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/errutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/signalutil" +) + +// Attach attaches stdin, stdout, and stderr to a running container. +func Attach(ctx context.Context, client *containerd.Client, req string, options types.ContainerAttachOptions) error { + // Find the container. + var container containerd.Container + var cStatus containerd.Status + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + container = found.Container + return nil + }, + } + n, err := walker.Walk(ctx, req) + if err != nil { + return fmt.Errorf("error when trying to find the container: %w", err) + } + if n == 0 { + return fmt.Errorf("no container is found given the string: %s", req) + } else if n > 1 { + return fmt.Errorf("more than one containers are found given the string: %s", req) + } + + defer func() { + containerLabels, err := container.Labels(ctx) + if err != nil { + log.G(ctx).WithError(err).Errorf("failed to getting container labels: %s", err) + return + } + rm, err := containerutil.DecodeContainerRmOptLabel(containerLabels[labels.ContainerAutoRemove]) + if err != nil { + log.G(ctx).WithError(err).Errorf("failed to decode string to bool value: %s", err) + return + } + if rm && cStatus.Status == containerd.Stopped { + if err = RemoveContainer(ctx, container, options.GOptions, true, true, client); err != nil { + log.L.WithError(err).Warnf("failed to remove container %s: %s", req, err) + } + } + }() + + // Attach to the container. + var task containerd.Task + detachC := make(chan struct{}) + spec, err := container.Spec(ctx) + if err != nil { + return fmt.Errorf("failed to get the OCI runtime spec for the container: %w", err) + } + var ( + opt cio.Opt + con console.Console + ) + if spec.Process.Terminal { + con, err = consoleutil.Current() + if err != nil { + return err + } + defer con.Reset() + if err := con.SetRaw(); err != nil { + return fmt.Errorf("failed to set the console to raw mode: %w", err) + } + closer := func() { + detachC <- struct{}{} + // task will be set by container.Task later. + // + // We cannot use container.Task(ctx, cio.Load) to get the IO here + // because the `cancel` field of the returned `*cio` is nil. [1] + // + // [1] https://github.com/containerd/containerd/blob/8f756bc8c26465bd93e78d9cd42082b66f276e10/cio/io.go#L358-L359 + io := task.IO() + if io == nil { + log.G(ctx).Errorf("got a nil io") + return + } + io.Cancel() + } + in, err := consoleutil.NewDetachableStdin(con, options.DetachKeys, closer) + if err != nil { + return err + } + opt = cio.WithStreams(in, con, nil) + } else { + opt = cio.WithStreams(options.Stdin, options.Stdout, options.Stderr) + } + task, err = container.Task(ctx, cio.NewAttach(opt)) + if err != nil { + return fmt.Errorf("failed to attach to the container: %w", err) + } + if spec.Process.Terminal { + 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) + + // Wait for the container to exit. + statusC, err := task.Wait(ctx) + if err != nil { + return fmt.Errorf("failed to init an async wait for the container to exit: %w", err) + } + select { + // io.Wait() would return when either 1) the user detaches from the container OR 2) the container is about to exit. + // + // If we replace the `select` block with io.Wait() and + // directly use task.Status() to check the status of the container after io.Wait() returns, + // it can still be running even though the container is about to exit (somehow especially for Windows). + // + // As a result, we need a separate detachC to distinguish from the 2 cases mentioned above. + case <-detachC: + io := task.IO() + if io == nil { + return errors.New("got a nil IO from the task") + } + io.Wait() + case status := <-statusC: + cStatus, err = task.Status(ctx) + if err != nil { + return err + } + code, _, err := status.Result() + if err != nil { + return err + } + if code != 0 { + return errutil.NewExitCoderErr(int(code)) + } + } + return nil +} diff --git a/pkg/cmd/container/commit.go b/pkg/cmd/container/commit.go index 2b9a556fcd7..1e089c7e92c 100644 --- a/pkg/cmd/container/commit.go +++ b/pkg/cmd/container/commit.go @@ -22,17 +22,18 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/imgutil/commit" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/sirupsen/logrus" + 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/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil/commit" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) // Commit will commit a container’s file changes or settings into a new image. func Commit(ctx context.Context, client *containerd.Client, rawRef string, req string, options types.ContainerCommitOptions) error { - named, err := referenceutil.ParseDockerRef(rawRef) + parsedReference, err := referenceutil.Parse(rawRef) if err != nil { return err } @@ -45,7 +46,7 @@ func Commit(ctx context.Context, client *containerd.Client, rawRef string, req s opts := &commit.Opts{ Author: options.Author, Message: options.Message, - Ref: named.String(), + Ref: parsedReference.String(), Pause: options.Pause, Changes: changes, } @@ -56,11 +57,11 @@ func Commit(ctx context.Context, client *containerd.Client, rawRef string, req s if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - imageID, err := commit.Commit(ctx, client, found.Container, opts) + imageID, err := commit.Commit(ctx, client, found.Container, opts, options.GOptions) if err != nil { return err } - _, err = fmt.Fprintf(options.Stdout, "%s\n", imageID) + _, err = fmt.Fprintln(options.Stdout, imageID) return err }, } @@ -96,7 +97,7 @@ func parseChanges(userChanges []string) (commit.Changes, error) { return commit.Changes{}, fmt.Errorf("malformed json in change flag value %q", change) } if changes.CMD != nil { - logrus.Warn("multiple change flags supplied for the CMD directive, overriding with last supplied") + log.L.Warn("multiple change flags supplied for the CMD directive, overriding with last supplied") } changes.CMD = overrideCMD case entrypointDirective: @@ -105,7 +106,7 @@ func parseChanges(userChanges []string) (commit.Changes, error) { return commit.Changes{}, fmt.Errorf("malformed json in change flag value %q", change) } if changes.Entrypoint != nil { - logrus.Warnf("multiple change flags supplied for the Entrypoint directive, overriding with last supplied") + log.L.Warnf("multiple change flags supplied for the Entrypoint directive, overriding with last supplied") } changes.Entrypoint = overrideEntrypoint default: // TODO: Support the rest of the change directives diff --git a/pkg/cmd/container/cp_linux.go b/pkg/cmd/container/cp_linux.go index c1d7dcca8ec..0763a376793 100644 --- a/pkg/cmd/container/cp_linux.go +++ b/pkg/cmd/container/cp_linux.go @@ -18,18 +18,44 @@ package container import ( "context" + "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Cp copies files/folders between a running container and the local filesystem. -func Cp(ctx context.Context, options types.ContainerCpOptions) error { - return containerutil.CopyFiles( - ctx, - options.Container2Host, - options.Pid, - options.DestPath, - options.SrcPath, - options.FollowSymLink) +func Cp(ctx context.Context, client *containerd.Client, options types.ContainerCpOptions) error { + 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.CopyFiles( + ctx, + client, + found.Container, + options) + }, + } + count, err := walker.Walk(ctx, options.ContainerReq) + + if count == -1 { + if err == nil { + panic("nil error and count == -1 from ContainerWalker.Walk should never happen") + } + err = fmt.Errorf("unable to copy: %w", err) + } else if count == 0 { + if err != nil { + err = fmt.Errorf("unable to retrieve containers with error: %w", err) + } else { + err = fmt.Errorf("no container found for: %s", options.ContainerReq) + } + } + + return err } diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 558b798ac4b..72be3454299 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -24,40 +24,60 @@ import ( "net/url" "os" "os/exec" - "path" "path/filepath" "runtime" "strconv" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/image" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/flagutil" - "github.com/containerd/nerdctl/pkg/idgen" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/logging" - "github.com/containerd/nerdctl/pkg/mountutil" - "github.com/containerd/nerdctl/pkg/namestore" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/containerd/nerdctl/pkg/strutil" dockercliopts "github.com/docker/cli/opts" - dockeropts "github.com/docker/docker/opts" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "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" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/cmd/volume" + "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/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/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/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/store" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // Create will create a container. func Create(ctx context.Context, client *containerd.Client, args []string, netManager containerutil.NetworkOptionsManager, options types.ContainerCreateOptions) (containerd.Container, func(), error) { + // Acquire an exclusive lock on the volume store until we are done to avoid being raced by any other + // volume operations (or any other operation involving volume manipulation) + volStore, err := volume.Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) + if err != nil { + return nil, nil, err + } + err = volStore.Lock() + if err != nil { + return nil, nil, err + } + defer volStore.Release() + // simulate the behavior of double dash newArg := []string{} if len(args) >= 2 && args[1] == "--" { @@ -99,10 +119,43 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa platformOpts, err := setPlatformOptions(ctx, client, id, netManager.NetworkOptions().UTSNamespace, &internalLabels, options) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } opts = append(opts, platformOpts...) + if _, err := referenceutil.Parse(args[0]); errors.Is(err, referenceutil.ErrLoadOCIArchiveRequired) { + imageRef := args[0] + + // Load and create the platform specified by the user. + // If none specified, fallback to the default platform. + platform := []string{} + if options.Platform != "" { + platform = append(platform, options.Platform) + } + + images, err := load.FromOCIArchive(ctx, client, imageRef, types.ImageLoadOptions{ + Stdout: options.Stdout, + GOptions: options.GOptions, + Platform: platform, + AllPlatforms: false, + Quiet: options.ImagePullOpt.Quiet, + }) + if err != nil { + return nil, nil, err + } else if len(images) == 0 { + // This is a regression and should not occur. + return nil, nil, errors.New("OCI archive did not contain any images") + } + + image := images[0].Name + // Multiple images loaded from the provided archive. Default to the first image found. + if len(images) != 1 { + log.L.Warnf("multiple images are found for the platform, defaulting to image %s...", image) + } + + args[0] = image + } + var ensuredImage *imgutil.EnsuredImage if !options.Rootfs { var platformSS []string // len: 0 or 1 @@ -111,19 +164,23 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } ocispecPlatforms, err := platformutil.NewOCISpecPlatformSlice(false, platformSS) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } rawRef := args[0] - ensuredImage, err = image.EnsureImage(ctx, client, rawRef, ocispecPlatforms, options.Pull, nil, false, options.ImagePullOpt) + options.ImagePullOpt.Mode = options.Pull + options.ImagePullOpt.OCISpecPlatform = ocispecPlatforms + options.ImagePullOpt.Unpack = nil + + ensuredImage, err = image.EnsureImage(ctx, client, rawRef, options.ImagePullOpt) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } } rootfsOpts, rootfsCOpts, err := generateRootfsOpts(args, id, ensuredImage, options) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } opts = append(opts, rootfsOpts...) cOpts = append(cOpts, rootfsCOpts...) @@ -134,13 +191,12 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa envs, err := flagutil.MergeEnvFileAndOSEnv(options.EnvFile, options.Env) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } - opts = append(opts, oci.WithEnv(envs)) if options.Interactive { if options.Detach { - return nil, nil, errors.New("currently flag -i and -d cannot be specified together (FIXME)") + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), errors.New("currently flag -i and -d cannot be specified together (FIXME)") } } @@ -149,9 +205,9 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } var mountOpts []oci.SpecOpts - mountOpts, internalLabels.anonVolumes, internalLabels.mountPoints, err = generateMountOpts(ctx, client, ensuredImage, options) + mountOpts, internalLabels.anonVolumes, internalLabels.mountPoints, err = generateMountOpts(ctx, client, ensuredImage, volStore, options) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } opts = append(opts, mountOpts...) @@ -161,40 +217,38 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa // 1, nerdctl run --name demo -it imagename // 2, ctrl + c to stop demo container // 3, nerdctl start/restart demo - logConfig, err := generateLogConfig(dataStore, id, options.LogDriver, options.LogOpt, options.GOptions.Namespace) + logConfig, err := generateLogConfig(dataStore, id, options.LogDriver, options.LogOpt, options.GOptions.Namespace, options.GOptions.Address) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } internalLabels.logURI = logConfig.LogURI restartOpts, err := generateRestartOpts(ctx, client, options.Restart, logConfig.LogURI, options.InRun) if err != nil { - return nil, nil, err + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), err } cOpts = append(cOpts, restartOpts...) - cOpts = append(cOpts, withStop(options.StopSignal, options.StopTimeout, ensuredImage)) if err = netManager.VerifyNetworkOptions(ctx); err != nil { - return nil, nil, fmt.Errorf("failed to verify networking settings: %s", err) + return nil, generateRemoveStateDirFunc(ctx, id, internalLabels), fmt.Errorf("failed to verify networking settings: %s", err) } netOpts, netNewContainerOpts, err := netManager.ContainerNetworkingOpts(ctx, id) if err != nil { - return nil, nil, fmt.Errorf("failed to generate networking spec options: %s", err) + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), fmt.Errorf("failed to generate networking spec options: %s", err) } opts = append(opts, netOpts...) cOpts = append(cOpts, netNewContainerOpts...) netLabelOpts, err := netManager.InternalNetworkingOptionLabels(ctx) if err != nil { - return nil, nil, fmt.Errorf("failed to generate internal networking labels: %s", err) + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), fmt.Errorf("failed to generate internal networking labels: %s", err) } - // TODO(aznashwan): more formal way to load net opts into internalLabels: - internalLabels.hostname = netLabelOpts.Hostname - internalLabels.ports = netLabelOpts.PortMappings - internalLabels.ipAddress = netLabelOpts.IPAddress - internalLabels.networks = netLabelOpts.NetworkSlice - internalLabels.macAddress = netLabelOpts.MACAddress + + envs = append(envs, "HOSTNAME="+netLabelOpts.Hostname) + opts = append(opts, oci.WithEnv(envs)) + + internalLabels.loadNetOpts(netLabelOpts) // NOTE: OCI hooks are currently not supported on Windows so we skip setting them altogether. // The OCI hooks we define (whose logic can be found in pkg/ocihook) primarily @@ -203,37 +257,37 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa if runtime.GOOS != "windows" { hookOpt, err := withNerdctlOCIHook(options.NerdctlCmd, options.NerdctlArgs) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } opts = append(opts, hookOpt) } uOpts, err := generateUserOpts(options.User) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } opts = append(opts, uOpts...) gOpts, err := generateGroupsOpts(options.GroupAdd) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } opts = append(opts, gOpts...) umaskOpts, err := generateUmaskOpts(options.Umask) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } opts = append(opts, umaskOpts...) rtCOpts, err := generateRuntimeCOpts(options.GOptions.CgroupManager, options.Runtime) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } cOpts = append(cOpts, rtCOpts...) - lCOpts, err := withContainerLabels(options.Label, options.LabelFile) + lCOpts, err := withContainerLabels(options.Label, options.LabelFile, ensuredImage) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } cOpts = append(cOpts, lCOpts...) @@ -244,43 +298,42 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa if ensuredImage != nil { imageRef = ensuredImage.Ref } - options.Name = referenceutil.SuggestContainerName(imageRef, id) + parsedReference, err := referenceutil.Parse(imageRef) + // Ignore cases where the imageRef is "" + if err != nil && imageRef != "" { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err + } + options.Name = parsedReference.SuggestContainerName(id) } if options.Name != "" { containerNameStore, err = namestore.New(dataStore, options.GOptions.Namespace) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } if err := containerNameStore.Acquire(options.Name, id); err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } } internalLabels.name = options.Name internalLabels.pidFile = options.PidFile - internalLabels.extraHosts = strutil.DedupeStrSlice(netManager.NetworkOptions().AddHost) - for i, host := range internalLabels.extraHosts { - if _, err := dockercliopts.ValidateExtraHost(host); err != nil { - return nil, nil, err - } - parts := strings.SplitN(host, ":", 2) - // If the IP Address is a string called "host-gateway", replace this value with the IP address stored - // in the daemon level HostGateway IP config variable. - if parts[1] == dockeropts.HostGatewayName { - if options.GOptions.HostGatewayIP == "" { - return nil, nil, fmt.Errorf("unable to derive the IP value for host-gateway") - } - parts[1] = options.GOptions.HostGatewayIP - internalLabels.extraHosts[i] = fmt.Sprintf(`%s:%s`, parts[0], parts[1]) - } + + extraHosts, err := containerutil.ParseExtraHosts(netManager.NetworkOptions().AddHost, options.GOptions.HostGatewayIP, ":") + if err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } + internalLabels.extraHosts = extraHosts + + internalLabels.rm = containerutil.EncodeContainerRmOptLabel(options.Rm) + // TODO: abolish internal labels and only use annotations ilOpt, err := withInternalLabels(internalLabels) if err != nil { - return nil, nil, err + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } cOpts = append(cOpts, ilOpt) - opts = append(opts, propagateContainerdLabelsToOCIAnnotations()) + opts = append(opts, propagateInternalContainerdLabelsToOCIAnnotations(), + oci.WithAnnotations(strutil.ConvertKVStringsToMap(options.Annotations))) var s specs.Spec spec := containerd.WithSpec(&s, opts...) @@ -289,12 +342,11 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa c, containerErr := client.NewContainer(ctx, id, cOpts...) var netSetupErr error - // NOTE: on non-Windows platforms, network setup is performed by OCI hooks. - // Seeing as though Windows does not currently support OCI hooks, we must explicitly - // perform network setup/teardown in the main nerdctl executable. - if containerErr == nil && runtime.GOOS == "windows" { + if containerErr == nil { netSetupErr = netManager.SetupNetworking(ctx, id) - logrus.WithError(netSetupErr).Warnf("networking setup error has occurred") + if netSetupErr != nil { + log.G(ctx).WithError(netSetupErr).Warnf("networking setup error has occurred") + } } if containerErr != nil || netSetupErr != nil { @@ -323,10 +375,9 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, for ind, env := range ensured.ImageConfig.Env { if strings.HasPrefix(env, "PATH=") { break - } else { - if ind == len(ensured.ImageConfig.Env)-1 { - opts = append(opts, oci.WithDefaultPathEnv) - } + } + if ind == len(ensured.ImageConfig.Env)-1 { + opts = append(opts, oci.WithDefaultPathEnv) } } } else { @@ -337,6 +388,15 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, opts = append(opts, oci.WithRootFSPath(absRootfs), oci.WithDefaultPathEnv) } + entrypointPath := "" + if ensured != nil { + if len(ensured.ImageConfig.Entrypoint) > 0 { + entrypointPath = ensured.ImageConfig.Entrypoint[0] + } else if len(ensured.ImageConfig.Cmd) > 0 { + entrypointPath = ensured.ImageConfig.Cmd[0] + } + } + if !options.Rootfs && !options.EntrypointChanged { opts = append(opts, oci.WithImageConfigArgs(ensured.Image, args[1:])) } else { @@ -354,8 +414,47 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, // error message is from Podman return nil, nil, errors.New("no command or entrypoint provided, and no CMD or ENTRYPOINT from image") } + + entrypointPath = processArgs[0] + opts = append(opts, oci.WithProcessArgs(processArgs...)) } + + isEntryPointSystemd := (entrypointPath == "/sbin/init" || + entrypointPath == "/usr/sbin/init" || + entrypointPath == "/usr/local/sbin/init") + + stopSignal := options.StopSignal + + if options.Systemd == "always" || (options.Systemd == "true" && isEntryPointSystemd) { + if options.Privileged { + securityOptsMap := strutil.ConvertKVStringsToMap(strutil.DedupeStrSlice(options.SecurityOpt)) + privilegedWithoutHostDevices, err := maputil.MapBoolValueAsOpt(securityOptsMap, "privileged-without-host-devices") + if err != nil { + return nil, nil, err + } + + // See: https://github.com/containers/podman/issues/15878 + if !privilegedWithoutHostDevices { + return nil, nil, errors.New("if --privileged is used with systemd `--security-opt privileged-without-host-devices` must also be used") + } + } + + opts = append(opts, + oci.WithoutMounts("/sys/fs/cgroup"), + oci.WithMounts([]specs.Mount{ + {Type: "cgroup", Source: "cgroup", Destination: "/sys/fs/cgroup", Options: []string{"rw"}}, + {Type: "tmpfs", Source: "tmpfs", Destination: "/run"}, + {Type: "tmpfs", Source: "tmpfs", Destination: "/run/lock"}, + {Type: "tmpfs", Source: "tmpfs", Destination: "/tmp"}, + {Type: "tmpfs", Source: "tmpfs", Destination: "/var/lib/journal"}, + }), + ) + stopSignal = "SIGRTMIN+3" + } + + cOpts = append(cOpts, withStop(stopSignal, options.StopTimeout, ensured)) + if options.InitBinary != nil { options.InitProcessFlag = true } @@ -385,23 +484,6 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, return opts, cOpts, nil } -// withBindMountHostIPC replaces /dev/shm and /dev/mqueue mount with rbind. -// Required for --ipc=host on rootless. -func withBindMountHostIPC(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { - for i, m := range s.Mounts { - switch p := path.Clean(m.Destination); p { - case "/dev/shm", "/dev/mqueue": - s.Mounts[i] = specs.Mount{ - Destination: p, - Type: "bind", - Source: p, - Options: []string{"rbind", "nosuid", "noexec", "nodev"}, - } - } - } - return nil -} - // GenerateLogURI generates a log URI for the current container store func GenerateLogURI(dataStore string) (*url.URL, error) { selfExe, err := os.Executable() @@ -416,6 +498,29 @@ func GenerateLogURI(dataStore string) (*url.URL, error) { } func withNerdctlOCIHook(cmd string, args []string) (oci.SpecOpts, error) { + if rootlessutil.IsRootless() { + detachedNetNS, err := rootlessutil.DetachedNetNS() + if err != nil { + return nil, fmt.Errorf("failed to check whether RootlessKit is running with --detach-netns: %w", err) + } + if detachedNetNS != "" { + // Rewrite {cmd, args} if RootlessKit is running with --detach-netns, so that the hook can gain + // CAP_NET_ADMIN in the namespaces. + // - Old: + // - cmd: "/usr/local/bin/nerdctl" + // - args: {"--data-root=/foo", "internal", "oci-hook"} + // - New: + // - cmd: "/usr/bin/nsenter" + // - args: {"-n/run/user/1000/containerd-rootless/netns", "-F", "--", "/usr/local/bin/nerdctl", "--data-root=/foo", "internal", "oci-hook"} + oldCmd, oldArgs := cmd, args + cmd, err = exec.LookPath("nsenter") + if err != nil { + return nil, err + } + args = append([]string{"-n" + detachedNetNS, "-F", "--", oldCmd}, oldArgs...) + } + } + args = append([]string{cmd}, append(args, "internal", "oci-hook")...) return func(_ context.Context, _ oci.Client, _ *containers.Container, s *specs.Spec) error { if s.Hooks == nil { @@ -438,13 +543,30 @@ func withNerdctlOCIHook(cmd string, args []string) (oci.SpecOpts, error) { }, nil } -func withContainerLabels(label, labelFile []string) ([]containerd.NewContainerOpts, error) { +func withContainerLabels(label, labelFile []string, ensuredImage *imgutil.EnsuredImage) ([]containerd.NewContainerOpts, error) { + var opts []containerd.NewContainerOpts + + // add labels defined by image + if ensuredImage != nil { + imageLabelOpts := containerd.WithAdditionalContainerLabels(ensuredImage.ImageConfig.Labels) + opts = append(opts, imageLabelOpts) + } + labelMap, err := readKVStringsMapfFromLabel(label, labelFile) if err != nil { return nil, err } + for k := range labelMap { + if strings.HasPrefix(k, annotations.Bypass4netns) { + log.L.Warnf("Label %q is deprecated, use an annotation instead", k) + } else if strings.HasPrefix(k, labels.Prefix) { + return nil, fmt.Errorf("internal label %q must not be specified manually", k) + } + } o := containerd.WithAdditionalContainerLabels(labelMap) - return []containerd.NewContainerOpts{o}, nil + opts = append(opts, o) + + return opts, nil } func readKVStringsMapfFromLabel(label, labelFile []string) (map[string]string, error) { @@ -486,7 +608,7 @@ func withStop(stopSignal string, stopTimeout int, ensuredImage *imgutil.EnsuredI } c.Labels[containerd.StopSignalLabel] = stopSignal if stopTimeout != 0 { - c.Labels[labels.StopTimout] = strconv.Itoa(stopTimeout) + c.Labels[labels.StopTimeout] = strconv.Itoa(stopTimeout) } return nil } @@ -506,15 +628,20 @@ type internalLabels struct { // network networks []string ipAddress string - ports []gocni.PortMapping + ip6Address string + ports []cni.PortMapping macAddress string // volume mountPoints []*mountutil.Processed anonVolumes []string // pid namespace pidContainer string + // ipc namespace & dev/shm + ipc string // log logURI string + // a label to check whether the --rm option is specified. + rm string } // WithInternalLabels sets the internal labels for a container. @@ -562,6 +689,10 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO m[labels.IPAddress] = internalLabels.ipAddress } + if internalLabels.ip6Address != "" { + m[labels.IP6Address] = internalLabels.ip6Address + } + m[labels.Platform], err = platformutil.NormalizeString(internalLabels.platform) if err != nil { return nil, err @@ -584,9 +715,27 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO m[labels.PIDContainer] = internalLabels.pidContainer } + if internalLabels.ipc != "" { + m[labels.IPC] = internalLabels.ipc + } + + if internalLabels.rm != "" { + m[labels.ContainerAutoRemove] = internalLabels.rm + } + return containerd.WithAdditionalContainerLabels(m), nil } +// loadNetOpts loads network options into InternalLabels. +func (il *internalLabels) loadNetOpts(opts types.NetworkOptions) { + il.hostname = opts.Hostname + il.ports = opts.PortMappings + il.ipAddress = opts.IPAddress + il.ip6Address = opts.IP6Address + il.networks = opts.NetworkSlice + il.macAddress = opts.MACAddress +} + func dockercompatMounts(mountPoints []*mountutil.Processed) []dockercompat.MountPoint { result := make([]dockercompat.MountPoint, len(mountPoints)) for i := range mountPoints { @@ -599,6 +748,7 @@ func dockercompatMounts(mountPoints []*mountutil.Processed) []dockercompat.Mount Driver: "", Mode: mp.Mode, } + result[i].RW, result[i].Propagation = dockercompat.ParseMountProperties(strings.Split(mp.Mode, ",")) // it's an anonymous volume if mp.AnonymousVolume != "" { @@ -613,14 +763,38 @@ func dockercompatMounts(mountPoints []*mountutil.Processed) []dockercompat.Mount return result } -func propagateContainerdLabelsToOCIAnnotations() oci.SpecOpts { +func processeds(mountPoints []dockercompat.MountPoint) []*mountutil.Processed { + result := make([]*mountutil.Processed, len(mountPoints)) + for i := range mountPoints { + mp := mountPoints[i] + result[i] = &mountutil.Processed{ + Type: mp.Type, + Name: mp.Name, + Mount: specs.Mount{ + Source: mp.Source, + Destination: mp.Destination, + }, + Mode: mp.Mode, + } + } + return result +} + +func propagateInternalContainerdLabelsToOCIAnnotations() oci.SpecOpts { return func(ctx context.Context, oc oci.Client, c *containers.Container, s *oci.Spec) error { - return oci.WithAnnotations(c.Labels)(ctx, oc, c, s) + allowed := make(map[string]string) + for k, v := range c.Labels { + if strings.Contains(k, labels.Prefix) { + allowed[k] = v + } + } + return oci.WithAnnotations(allowed)(ctx, oc, c, s) } } func writeCIDFile(path, id string) error { - if _, err := os.Stat(path); err == nil { + _, err := os.Stat(path) + if err == nil { return fmt.Errorf("container ID file found, make sure the other container isn't running or delete %s", path) } else if errors.Is(err, os.ErrNotExist) { f, err := os.Create(path) @@ -633,77 +807,119 @@ func writeCIDFile(path, id string) error { return err } return nil - } else { - return err } + return err } // generateLogConfig creates a LogConfig for the current container store -func generateLogConfig(dataStore string, id string, logDriver string, logOpt []string, ns string) (logConfig logging.LogConfig, err error) { +func generateLogConfig(dataStore string, id string, logDriver string, logOpt []string, ns, address string) (logConfig logging.LogConfig, err error) { var u *url.URL if u, err = url.Parse(logDriver); err == nil && u.Scheme != "" { logConfig.LogURI = logDriver } else { logConfig.Driver = logDriver + logConfig.Address = address logConfig.Opts, err = parseKVStringsMapFromLogOpt(logOpt, logDriver) if err != nil { - return + return logConfig, err } var ( logDriverInst logging.Driver logConfigB []byte lu *url.URL ) - logDriverInst, err = logging.GetDriver(logDriver, logConfig.Opts) + logDriverInst, err = logging.GetDriver(logDriver, logConfig.Opts, logConfig.Address) if err != nil { - return + return logConfig, err } if err = logDriverInst.Init(dataStore, ns, id); err != nil { - return + return logConfig, err } logConfigB, err = json.Marshal(logConfig) if err != nil { - return + return logConfig, err } logConfigFilePath := logging.LogConfigFilePath(dataStore, ns, id) if err = os.WriteFile(logConfigFilePath, logConfigB, 0600); err != nil { - return + return logConfig, err } lu, err = GenerateLogURI(dataStore) if err != nil { - return + return logConfig, err } if lu != nil { - logrus.Debugf("generated log driver: %s", lu.String()) + log.L.Debugf("generated log driver: %s", lu.String()) logConfig.LogURI = lu.String() } } return logConfig, nil } +func generateRemoveStateDirFunc(ctx context.Context, id string, internalLabels internalLabels) func() { + return func() { + if rmErr := os.RemoveAll(internalLabels.stateDir); rmErr != nil { + log.G(ctx).WithError(rmErr).Warnf("failed to remove container %q state dir %q", id, internalLabels.stateDir) + } + } +} + +func generateRemoveOrphanedDirsFunc(ctx context.Context, id, dataStore string, internalLabels internalLabels) func() { + return func() { + if rmErr := os.RemoveAll(internalLabels.stateDir); rmErr != nil { + log.G(ctx).WithError(rmErr).Warnf("failed to remove container %q state dir %q", id, internalLabels.stateDir) + } + + hs, err := hostsstore.New(dataStore, internalLabels.namespace) + if err != nil { + log.G(ctx).WithError(err).Warnf("failed to instantiate hostsstore for %q", internalLabels.namespace) + } else if err = hs.Delete(id); err != nil { + log.G(ctx).WithError(err).Warnf("failed to remove an etchosts directory for container %q", id) + } + } +} + func generateGcFunc(ctx context.Context, container containerd.Container, ns, id, name, dataStore string, containerErr error, containerNameStore namestore.NameStore, netManager containerutil.NetworkOptionsManager, internalLabels internalLabels) func() { return func() { if containerErr == nil { netGcErr := netManager.CleanupNetworking(ctx, container) if netGcErr != nil { - logrus.WithError(netGcErr).Warnf("failed to revert container %q networking settings", id) + log.G(ctx).WithError(netGcErr).Warnf("failed to revert container %q networking settings", id) + } + } else { + hs, err := hostsstore.New(dataStore, internalLabels.namespace) + if err != nil { + log.G(ctx).WithError(err).Warnf("failed to instantiate hostsstore for %q", internalLabels.namespace) + } else { + if _, err := hs.HostsPath(id); err != nil { + log.G(ctx).WithError(err).Warnf("an etchosts directory for container %q dosen't exist", id) + } else if err = hs.Delete(id); err != nil { + log.G(ctx).WithError(err).Warnf("failed to remove an etchosts directory for container %q", id) + } } } + ipc, ipcErr := ipcutil.DecodeIPCLabel(internalLabels.ipc) + if ipcErr != nil { + log.G(ctx).WithError(ipcErr).Warnf("failed to decode ipc label for container %q", id) + } + if ipcErr := ipcutil.CleanUp(ipc); ipcErr != nil { + log.G(ctx).WithError(ipcErr).Warnf("failed to clean up ipc for container %q", id) + } if rmErr := os.RemoveAll(internalLabels.stateDir); rmErr != nil { - logrus.WithError(rmErr).Warnf("failed to remove container %q state dir %q", id, internalLabels.stateDir) + 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 { - logrus.WithError(errE).Warnf("failed to instantiate container name store during cleanup for container %q", id) + log.G(ctx).WithError(errE).Warnf("failed to instantiate container name store during cleanup for container %q", id) } - if errE = containerNameStore.Release(name, id); errE != nil { - logrus.WithError(errE).Warnf("failed to release container name store for container %q (%s)", name, 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/exec.go b/pkg/cmd/container/exec.go index ac163b85a23..c00c776998b 100644 --- a/pkg/cmd/container/exec.go +++ b/pkg/cmd/container/exec.go @@ -22,18 +22,20 @@ import ( "io" "os" - "github.com/containerd/console" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/consoleutil" - "github.com/containerd/nerdctl/pkg/flagutil" - "github.com/containerd/nerdctl/pkg/idgen" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/signalutil" - "github.com/containerd/nerdctl/pkg/taskutil" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + "github.com/containerd/console" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/consoleutil" + "github.com/containerd/nerdctl/v2/pkg/flagutil" + "github.com/containerd/nerdctl/v2/pkg/idgen" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/signalutil" + "github.com/containerd/nerdctl/v2/pkg/taskutil" ) // Exec will find the right running container to run a new command. @@ -104,7 +106,10 @@ func execActionWithContainer(ctx context.Context, client *containerd.Client, con var con console.Console if options.TTY { - con = console.Current() + con, err = consoleutil.Current() + if err != nil { + return err + } defer con.Reset() if err := con.SetRaw(); err != nil { return err @@ -113,7 +118,7 @@ func execActionWithContainer(ctx context.Context, client *containerd.Client, con if !options.Detach { if options.TTY { if err := consoleutil.HandleConsoleResize(ctx, process, con); err != nil { - logrus.WithError(err).Error("console resize") + log.G(ctx).WithError(err).Error("console resize") } } else { sigc := signalutil.ForwardAllSignals(ctx, process) @@ -162,7 +167,11 @@ func generateExecProcessSpec(ctx context.Context, client *containerd.Client, con pspec := spec.Process pspec.Terminal = options.TTY if pspec.Terminal { - if size, err := console.Current().Size(); err == nil { + con, err := consoleutil.Current() + if err != nil { + return nil, err + } + if size, err := con.Size(); err == nil { pspec.ConsoleSize = &specs.Box{Height: uint(size.Height), Width: uint(size.Width)} } } diff --git a/pkg/cmd/container/exec_linux.go b/pkg/cmd/container/exec_linux.go index 28fb7c09028..33159b77dc9 100644 --- a/pkg/cmd/container/exec_linux.go +++ b/pkg/cmd/container/exec_linux.go @@ -17,8 +17,9 @@ package container import ( - "github.com/containerd/containerd/pkg/cap" "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/containerd/containerd/v2/pkg/cap" ) func setExecCapabilities(pspec *specs.Process) error { diff --git a/pkg/cmd/container/inspect.go b/pkg/cmd/container/inspect.go index 07b5f3dca61..72db92d4248 100644 --- a/pkg/cmd/container/inspect.go +++ b/pkg/cmd/container/inspect.go @@ -21,19 +21,25 @@ import ( "fmt" "time" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerinspector" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" + "github.com/containerd/nerdctl/v2/pkg/containerinspector" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" ) // Inspect prints detailed information for each container in `containers`. func Inspect(ctx context.Context, client *containerd.Client, containers []string, options types.ContainerInspectOptions) error { f := &containerInspector{ - mode: options.Mode, + mode: options.Mode, + size: options.Size, + snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), } walker := &containerwalker.ContainerWalker{ @@ -44,15 +50,18 @@ func Inspect(ctx context.Context, client *containerd.Client, containers []string err := walker.WalkAll(ctx, containers, true) if len(f.entries) > 0 { if formatErr := formatter.FormatSlice(options.Format, options.Stdout, f.entries); formatErr != nil { - logrus.Error(formatErr) + log.L.Error(formatErr) } } + return err } type containerInspector struct { - mode string - entries []interface{} + mode string + size bool + snapshotter snapshots.Snapshotter + entries []interface{} } func (x *containerInspector) Handler(ctx context.Context, found containerwalker.Found) error { @@ -71,7 +80,15 @@ func (x *containerInspector) Handler(ctx context.Context, found containerwalker. if err != nil { return err } + if x.size { + resourceUsage, allResourceUsage, err := imgutil.ResourceUsage(ctx, x.snapshotter, d.ID) + if err == nil { + d.SizeRw = &resourceUsage.Size + d.SizeRootFs = &allResourceUsage.Size + } + } x.entries = append(x.entries, d) + return err default: return fmt.Errorf("unknown mode %q", x.mode) } diff --git a/pkg/cmd/container/kill.go b/pkg/cmd/container/kill.go index 5652dbf9031..ceb4ef9ac88 100644 --- a/pkg/cmd/container/kill.go +++ b/pkg/cmd/container/kill.go @@ -18,18 +18,28 @@ package container import ( "context" + "encoding/json" "fmt" "os" "strings" "syscall" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" "github.com/moby/sys/signal" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/errdefs" + "github.com/containerd/go-cni" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" + "github.com/containerd/nerdctl/v2/pkg/portutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) // Kill kills a list of containers @@ -49,6 +59,9 @@ func Kill(ctx context.Context, client *containerd.Client, reqs []string, options if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } + if err := cleanupNetwork(ctx, found.Container, options.GOptions); err != nil { + return fmt.Errorf("unable to cleanup network for container: %s, %q", found.Req, err) + } if err := killContainer(ctx, found.Container, parsedSignal); err != nil { if errdefs.IsNotFound(err) { fmt.Fprintf(options.Stderr, "No such container: %s\n", found.Req) @@ -56,7 +69,7 @@ func Kill(ctx context.Context, client *containerd.Client, reqs []string, options } return err } - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Container.ID()) + _, err := fmt.Fprintln(options.Stdout, found.Container.ID()) return err }, } @@ -64,7 +77,15 @@ func Kill(ctx context.Context, client *containerd.Client, reqs []string, options return walker.WalkAll(ctx, reqs, true) } -func killContainer(ctx context.Context, container containerd.Container, signal syscall.Signal) error { +func killContainer(ctx context.Context, container containerd.Container, signal syscall.Signal) (err error) { + defer func() { + if err != nil { + containerutil.UpdateErrorLabel(ctx, container, err) + } + }() + if err := containerutil.UpdateExplicitlyStoppedLabel(ctx, container, true); err != nil { + return err + } task, err := container.Task(ctx, cio.Load) if err != nil { return err @@ -92,8 +113,78 @@ func killContainer(ctx context.Context, container containerd.Container, signal s // signal will be sent once resume is finished if paused { if err := task.Resume(ctx); err != nil { - logrus.Warnf("cannot unpause container %s: %s", container.ID(), err) + log.G(ctx).Warnf("cannot unpause container %s: %s", container.ID(), err) } } return nil } + +// 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) + if err != nil { + return err + } + ports, portErr := portutil.ParsePortsLabel(info.Labels) + if portErr != nil { + return fmt.Errorf("no oci spec: %q", portErr) + } + portMappings := []cni.NamespaceOpts{ + cni.WithCapabilityPortMap(ports), + } + + // retrieve info to get cni instance + spec, err := container.Spec(ctx) + if err != nil { + return err + } + networksJSON := spec.Annotations[labels.Networks] + var networks []string + if err := json.Unmarshal([]byte(networksJSON), &networks); err != nil { + return err + } + netType, err := nettype.Detect(networks) + if err != nil { + return err + } + + switch netType { + case nettype.Host, nettype.None, nettype.Container, nettype.Namespace: + // NOP + case nettype.CNI: + e, err := netutil.NewCNIEnv(globalOpts.CNIPath, globalOpts.CNINetConfPath, netutil.WithNamespace(globalOpts.Namespace), netutil.WithDefaultNetwork(globalOpts.BridgeIP)) + if err != nil { + return err + } + cniOpts := []cni.Opt{ + cni.WithPluginDir([]string{globalOpts.CNIPath}), + } + var netw *netutil.NetworkConfig + for _, netstr := range networks { + if netw, err = e.NetworkByNameOrID(netstr); err != nil { + return err + } + cniOpts = append(cniOpts, cni.WithConfListBytes(netw.Bytes)) + } + cniObj, err := cni.New(cniOpts...) + if err != nil { + return err + } + + var namespaceOpts []cni.NamespaceOpts + namespaceOpts = append(namespaceOpts, portMappings...) + namespace := spec.Annotations[labels.Namespace] + fullID := namespace + "-" + container.ID() + if err := cniObj.Remove(ctx, fullID, "", namespaceOpts...); err != nil { + log.L.WithError(err).Errorf("failed to call cni.Remove") + return err + } + return nil + default: + return fmt.Errorf("unexpected network type %v", netType) + } + return nil + }) +} diff --git a/pkg/cmd/container/list.go b/pkg/cmd/container/list.go index 0db83bc60fb..b23dbb9e14b 100644 --- a/pkg/cmd/container/list.go +++ b/pkg/cmd/container/list.go @@ -17,36 +17,35 @@ package container import ( - "bytes" "context" "encoding/json" - "errors" "fmt" "sort" "strings" - "text/tabwriter" - "text/template" + "sync" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/labels/k8slabels" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/pkg/progress" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "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" ) // List prints containers according to `options`. -func List(ctx context.Context, client *containerd.Client, options types.ContainerListOptions) error { - containers, err := filterContainers(ctx, client, options.Filters, options.LastN, options.All) +func List(ctx context.Context, client *containerd.Client, options types.ContainerListOptions) ([]ListItem, error) { + containers, cMap, err := filterContainers(ctx, client, options.Filters, options.LastN, options.All) if err != nil { - return err + return nil, err } - return printContainers(ctx, client, containers, options) + return prepareContainers(ctx, client, containers, cMap, options) } // filterContainers returns containers matching the filters. @@ -55,44 +54,66 @@ func List(ctx context.Context, client *containerd.Client, options types.Containe // - all means showing all containers (default shows just running). // - lastN means only showing n last created containers (includes all states). Non-positive values are ignored. // In other words, if lastN is positive, all will be set to true. -func filterContainers(ctx context.Context, client *containerd.Client, filters []string, lastN int, all bool) ([]containerd.Container, error) { +func filterContainers(ctx context.Context, client *containerd.Client, filters []string, lastN int, all bool) ([]containerd.Container, map[string]string, error) { containers, err := client.Containers(ctx) if err != nil { - return nil, err + return nil, nil, err } filterCtx, err := foldContainerFilters(ctx, containers, filters) if err != nil { - return nil, err + return nil, nil, err } containers = filterCtx.MatchesFilters(ctx) + + sort.Slice(containers, func(i, j int) bool { + infoI, _ := containers[i].Info(ctx, containerd.WithoutRefreshedMetadata) + infoJ, _ := containers[j].Info(ctx, containerd.WithoutRefreshedMetadata) + return infoI.CreatedAt.After(infoJ.CreatedAt) + }) + if lastN > 0 { all = true - sort.Slice(containers, func(i, j int) bool { - infoI, _ := containers[i].Info(ctx, containerd.WithoutRefreshedMetadata) - infoJ, _ := containers[j].Info(ctx, containerd.WithoutRefreshedMetadata) - return infoI.CreatedAt.After(infoJ.CreatedAt) - }) if lastN < len(containers) { containers = containers[:lastN] } } - if all { - return containers, nil + var wg sync.WaitGroup + statusPerContainer := make(map[string]string) + var mu sync.Mutex + // formatter.ContainerStatus(ctx, c) is time consuming so we do it in goroutines and return the container's id with status as a map. + // prepareContainers func will use this map to avoid call formatter.ContainerStatus again. + for _, c := range containers { + if c.ID() == "" { + return nil, nil, fmt.Errorf("container id is nill") + } + wg.Add(1) + go func(ctx context.Context, c containerd.Container) { + defer wg.Done() + cStatus := formatter.ContainerStatus(ctx, c) + mu.Lock() + statusPerContainer[c.ID()] = cStatus + mu.Unlock() + }(ctx, c) + } + wg.Wait() + if all || filterCtx.all { + return containers, statusPerContainer, nil } + var upContainers []containerd.Container for _, c := range containers { - cStatus := formatter.ContainerStatus(ctx, c) + cStatus := statusPerContainer[c.ID()] if strings.HasPrefix(cStatus, "Up") { upContainers = append(upContainers, c) } } - return upContainers, nil + return upContainers, statusPerContainer, nil } -type containerPrintable struct { +type ListItem struct { Command string - CreatedAt string + CreatedAt time.Time ID string Image string Platform string // nerdctl extension @@ -102,227 +123,97 @@ type containerPrintable struct { Runtime string // nerdctl extension Size string Labels string + LabelsMap map[string]string `json:"-"` + // TODO: "LocalVolumes", "Mounts", "Networks", "RunningFor", "State" } -func printContainers(ctx context.Context, client *containerd.Client, containers []containerd.Container, options types.ContainerListOptions) error { - w := options.Stdout - var ( - wide bool - tmpl *template.Template - ) - switch options.Format { - case "", "table": - w = tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) - if !options.Quiet { - printHeader := "CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES" - if options.Size { - printHeader += "\tSIZE" - } - fmt.Fprintln(w, printHeader) - } - case "raw": - return errors.New("unsupported format: \"raw\"") - case "wide": - w = tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) - if !options.Quiet { - fmt.Fprintln(w, "CONTAINER ID\tIMAGE\tCOMMAND\tCREATED\tSTATUS\tPORTS\tNAMES\tRUNTIME\tPLATFORM\tSIZE") - wide = true - } - 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 - } - } +func (x *ListItem) Label(s string) string { + return x.LabelsMap[s] +} - for _, c := range containers { +func prepareContainers(ctx context.Context, client *containerd.Client, containers []containerd.Container, statusPerContainer map[string]string, options types.ContainerListOptions) ([]ListItem, error) { + listItems := make([]ListItem, len(containers)) + snapshottersCache := map[string]snapshots.Snapshotter{} + for i, c := range containers { info, err := c.Info(ctx, containerd.WithoutRefreshedMetadata) if err != nil { if errdefs.IsNotFound(err) { - logrus.Warn(err) + log.G(ctx).Warn(err) continue } - return err + return nil, err } - spec, err := c.Spec(ctx) if err != nil { if errdefs.IsNotFound(err) { - logrus.Warn(err) + log.G(ctx).Warn(err) continue } - return err + return nil, err } - - imageName := info.Image id := c.ID() if options.Truncate && len(id) > 12 { id = id[:12] } - - p := containerPrintable{ + var status string + if s, ok := statusPerContainer[c.ID()]; ok { + status = s + } else { + return nil, fmt.Errorf("can't get container %s status", c.ID()) + } + li := ListItem{ Command: formatter.InspectContainerCommand(spec, options.Truncate, true), - CreatedAt: info.CreatedAt.Round(time.Second).Local().String(), // format like "2021-08-07 02:19:45 +0900 JST" + CreatedAt: info.CreatedAt, ID: id, - Image: imageName, + Image: info.Image, Platform: info.Labels[labels.Platform], - Names: getPrintableContainerName(info.Labels), + Names: containerutil.GetContainerName(info.Labels), Ports: formatter.FormatPorts(info.Labels), - Status: formatter.ContainerStatus(ctx, c), + Status: status, Runtime: info.Runtime.Name, Labels: formatter.FormatLabels(info.Labels), + LabelsMap: info.Labels, } - - if options.Size || wide { - containerSize, err := getContainerSize(ctx, client, c, info) - if err != nil { - return err - } - p.Size = containerSize - } - - if tmpl != nil { - var b bytes.Buffer - if err := tmpl.Execute(&b, p); err != nil { - return err - } - if _, err = fmt.Fprintf(w, b.String()+"\n"); err != nil { - return err + if options.Size { + snapshotter, ok := snapshottersCache[info.Snapshotter] + if !ok { + snapshottersCache[info.Snapshotter] = containerdutil.SnapshotService(client, info.Snapshotter) + snapshotter = snapshottersCache[info.Snapshotter] } - } else if options.Quiet { - if _, err := fmt.Fprintf(w, "%s\n", id); err != nil { - return err - } - } else { - format := "%s\t%s\t%s\t%s\t%s\t%s\t%s" - args := []interface{}{ - p.ID, - p.Image, - p.Command, - formatter.TimeSinceInHuman(info.CreatedAt), - p.Status, - p.Ports, - p.Names, - } - if wide { - format += "\t%s\t%s\t%s\n" - args = append(args, p.Runtime, p.Platform, p.Size) - } else if options.Size { - format += "\t%s\n" - args = append(args, p.Size) - } else { - format += "\n" - } - if _, err := fmt.Fprintf(w, format, args...); err != nil { - return err - } - } - - } - if f, ok := w.(formatter.Flusher); ok { - return f.Flush() - } - return nil -} - -func getPrintableContainerName(containerLabels map[string]string) string { - if name, ok := containerLabels[labels.Name]; ok { - return name - } - - if ns, ok := containerLabels[k8slabels.PodNamespace]; ok { - if podName, ok := containerLabels[k8slabels.PodName]; ok { - if containerName, ok := containerLabels[k8slabels.ContainerName]; ok { - // Container - return fmt.Sprintf("k8s://%s/%s/%s", ns, podName, containerName) - } - // Pod sandbox - return fmt.Sprintf("k8s://%s/%s", ns, podName) - } - } - return "" -} - -type containerVolume struct { - Type string - Name string - Source string - Destination string - Mode string - RW bool - Propagation string -} - -func getContainerVolumes(containerLabels map[string]string) []*containerVolume { - var vols []*containerVolume - volLabels := []string{labels.AnonymousVolumes, labels.Mounts} - for _, volLabel := range volLabels { - names, ok := containerLabels[volLabel] - if !ok { - continue - } - var ( - volumes []*containerVolume - err error - ) - if volLabel == labels.Mounts { - err = json.Unmarshal([]byte(names), &volumes) - } - if volLabel == labels.AnonymousVolumes { - var anonymous []string - err = json.Unmarshal([]byte(names), &anonymous) - for _, anony := range anonymous { - volumes = append(volumes, &containerVolume{Name: anony}) + containerSize, err := getContainerSize(ctx, snapshotter, info.SnapshotKey) + if err != nil { + return nil, err } - - } - if err != nil { - logrus.Warn(err) + li.Size = containerSize } - vols = append(vols, volumes...) + listItems[i] = li } - return vols + return listItems, nil } func getContainerNetworks(containerLables map[string]string) []string { var networks []string if names, ok := containerLables[labels.Networks]; ok { if err := json.Unmarshal([]byte(names), &networks); err != nil { - logrus.Warn(err) + log.L.Warn(err) } } return networks } -func getContainerSize(ctx context.Context, client *containerd.Client, c containerd.Container, info containers.Container) (string, error) { +func getContainerSize(ctx context.Context, snapshotter snapshots.Snapshotter, snapshotKey string) (string, error) { // get container snapshot size - snapshotKey := info.SnapshotKey var containerSize int64 + var imageSize int64 if snapshotKey != "" { - usage, err := client.SnapshotService(info.Snapshotter).Usage(ctx, snapshotKey) + rw, all, err := imgutil.ResourceUsage(ctx, snapshotter, snapshotKey) if err != nil { return "", err } - containerSize = usage.Size - } - - // get the image interface - image, err := c.Image(ctx) - if err != nil { - return "", err - } - - sn := client.SnapshotService(info.Snapshotter) - - imageSize, err := imgutil.UnpackedImageSize(ctx, sn, image) - if err != nil { - return "", err + containerSize = rw.Size + imageSize = all.Size } return fmt.Sprintf("%s (virtual %s)", progress.Bytes(containerSize).String(), progress.Bytes(imageSize).String()), nil diff --git a/pkg/cmd/container/list_util.go b/pkg/cmd/container/list_util.go index 9a1aac22b37..c6e0da8e2eb 100644 --- a/pkg/cmd/container/list_util.go +++ b/pkg/cmd/container/list_util.go @@ -23,9 +23,11 @@ import ( "strings" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/containerutil" ) func foldContainerFilters(ctx context.Context, containers []containerd.Container, filters []string) (*containerFilterContext, error) { @@ -44,8 +46,10 @@ type containerFilterContext struct { sinceFilterFuncs []func(t time.Time) bool statusFilterFuncs []func(containerd.ProcessStatus) bool labelFilterFuncs []func(map[string]string) bool - volumeFilterFuncs []func([]*containerVolume) bool + volumeFilterFuncs []func([]*containerutil.ContainerVolume) bool networkFilterFuncs []func([]string) bool + + all bool } func (cl *containerFilterContext) MatchesFilters(ctx context.Context) []containerd.Container { @@ -120,7 +124,7 @@ func (cl *containerFilterContext) foldStatusFilter(_ context.Context, filter, va return containerd.Stopped == stats }) case containerd.ProcessStatus("restarting"), containerd.ProcessStatus("removing"), containerd.ProcessStatus("dead"): - logrus.Warnf("%s is not supported and is ignored", filter) + log.L.Warnf("%s is not supported and is ignored", filter) default: return fmt.Errorf("invalid filter '%s'", filter) } @@ -187,7 +191,7 @@ func (cl *containerFilterContext) foldLabelFilter(_ context.Context, filter, val } func (cl *containerFilterContext) foldVolumeFilter(_ context.Context, filter, value string) error { - cl.volumeFilterFuncs = append(cl.volumeFilterFuncs, func(vols []*containerVolume) bool { + cl.volumeFilterFuncs = append(cl.volumeFilterFuncs, func(vols []*containerutil.ContainerVolume) bool { for _, vol := range vols { if (vol.Source != "" && vol.Source == value) || (vol.Destination != "" && vol.Destination == value) || @@ -231,12 +235,12 @@ func (cl *containerFilterContext) matchesTaskFilters(ctx context.Context, contai defer cancel() task, err := container.Task(ctx, nil) if err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) return false } status, err := task.Status(ctx) if err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) return false } return cl.matchesExitedFilter(status) && cl.matchesStatusFilter(status) @@ -262,6 +266,7 @@ func (cl *containerFilterContext) matchesStatusFilter(status containerd.Status) if len(cl.statusFilterFuncs) == 0 { return true } + cl.all = true for _, statusFilterFunc := range cl.statusFilterFuncs { if !statusFilterFunc(status.Status) { continue @@ -288,7 +293,7 @@ func (cl *containerFilterContext) matchesNameFilter(info containers.Container) b if len(cl.nameFilterFuncs) == 0 { return true } - cName := getPrintableContainerName(info.Labels) + cName := containerutil.GetContainerName(info.Labels) for _, nameFilterFunc := range cl.nameFilterFuncs { if !nameFilterFunc(cName) { continue @@ -337,7 +342,7 @@ func (cl *containerFilterContext) matchesVolumeFilter(info containers.Container) if len(cl.volumeFilterFuncs) == 0 { return true } - vols := getContainerVolumes(info.Labels) + vols := containerutil.GetContainerVolumes(info.Labels) for _, volumeFilterFunc := range cl.volumeFilterFuncs { if !volumeFilterFunc(vols) { continue @@ -367,7 +372,7 @@ func idOrNameFilter(ctx context.Context, containers []containerd.Container, valu if err != nil { return nil, err } - if strings.HasPrefix(info.ID, value) || strings.Contains(getPrintableContainerName(info.Labels), value) { + if strings.HasPrefix(info.ID, value) || strings.Contains(containerutil.GetContainerName(info.Labels), value) { return &info, nil } } diff --git a/pkg/cmd/container/logs.go b/pkg/cmd/container/logs.go index c971a15d480..2deb143e6bc 100644 --- a/pkg/cmd/container/logs.go +++ b/pkg/cmd/container/logs.go @@ -23,16 +23,17 @@ import ( "os/signal" "syscall" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/api/types/cri" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/labels/k8slabels" - "github.com/containerd/nerdctl/pkg/logging" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/api/types/cri" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/labels/k8slabels" + "github.com/containerd/nerdctl/v2/pkg/logging" ) func Logs(ctx context.Context, client *containerd.Client, container string, options types.ContainerLogsOptions) error { @@ -43,7 +44,7 @@ func Logs(ctx context.Context, client *containerd.Client, container string, opti switch options.GOptions.Namespace { case "moby": - logrus.Warn("Currently, `nerdctl logs` only supports containers created with `nerdctl run -d` or CRI") + log.G(ctx).Warn("Currently, `nerdctl logs` only supports containers created with `nerdctl run -d` or CRI") } stopChannel := make(chan os.Signal, 1) @@ -90,7 +91,7 @@ func Logs(ctx context.Context, client *containerd.Client, container string, opti // Setup goroutine to send stop event if container task finishes: go func() { <-waitCh - logrus.Debugf("container task has finished, sending kill signal to log viewer") + log.G(ctx).Debugf("container task has finished, sending kill signal to log viewer") stopChannel <- os.Interrupt }() } diff --git a/pkg/cmd/container/pause.go b/pkg/cmd/container/pause.go index 86cd5130298..99451a8e3e5 100644 --- a/pkg/cmd/container/pause.go +++ b/pkg/cmd/container/pause.go @@ -20,10 +20,11 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Pause pauses all containers specified by `reqs`. @@ -38,7 +39,7 @@ func Pause(ctx context.Context, client *containerd.Client, reqs []string, option return err } - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(options.Stdout, found.Req) return err }, } diff --git a/pkg/cmd/container/prune.go b/pkg/cmd/container/prune.go index b53f9039dea..68614e0e42d 100644 --- a/pkg/cmd/container/prune.go +++ b/pkg/cmd/container/prune.go @@ -22,9 +22,10 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) // Prune remove all stopped containers @@ -36,14 +37,14 @@ func Prune(ctx context.Context, client *containerd.Client, options types.Contain var deleted []string for _, c := range containers { - if err = RemoveContainer(ctx, c, options.GOptions, false, true); err == nil { + if err = RemoveContainer(ctx, c, options.GOptions, false, true, client); err == nil { deleted = append(deleted, c.ID()) continue } if errors.As(err, &ErrContainerStatus{}) { continue } - logrus.WithError(err).Warnf("failed to remove container %s", c.ID()) + log.G(ctx).WithError(err).Warnf("failed to remove container %s", c.ID()) } if len(deleted) > 0 { diff --git a/pkg/cmd/container/remove.go b/pkg/cmd/container/remove.go index 90c0ef5231e..d425b718972 100644 --- a/pkg/cmd/container/remove.go +++ b/pkg/cmd/container/remove.go @@ -22,22 +22,24 @@ import ( "errors" "fmt" "os" - "runtime" "syscall" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/namespaces" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/cmd/volume" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/dnsutil/hostsstore" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/namestore" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/errdefs" + "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/dnsutil/hostsstore" + "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/store" ) var _ error = ErrContainerStatus{} @@ -69,170 +71,246 @@ func Remove(ctx context.Context, client *containerd.Client, containers []string, if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - if err := RemoveContainer(ctx, found.Container, options.GOptions, options.Force, options.Volumes); err != nil { + if err := RemoveContainer(ctx, found.Container, options.GOptions, options.Force, options.Volumes, client); err != nil { if errors.As(err, &ErrContainerStatus{}) { err = fmt.Errorf("%s. unpause/stop container first or force removal", err) } return err } - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(options.Stdout, found.Req) return err }, } err := walker.WalkAll(ctx, containers, true) if err != nil && options.Force { - logrus.Error(err) + log.G(ctx).Error(err) return nil } return err } // RemoveContainer removes a container from containerd store. -func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions types.GlobalCommandOptions, force bool, removeAnonVolumes bool) (retErr error) { - // defer the storage of remove error in the dedicated label +// It will first retrieve system objects (namestore, etcetera), then assess whether we should remove the container or not +// based of "force" and the status of the task. +// If we are to delete, it then kills and delete the task. +// If task removal fails, we stop (except if it was just "NotFound"). +// We then enter the defer cleanup function that will: +// - remove the network config (windows only) +// - delete the container +// - then and ONLY then, on a successful container remove, clean things-up on our side (volume store, etcetera) +// If you do need to add more cleanup, please do so at the bottom of the defer function +func RemoveContainer(ctx context.Context, c containerd.Container, globalOptions types.GlobalCommandOptions, force bool, removeAnonVolumes bool, client *containerd.Client) (retErr error) { + // Get labels + containerLabels, err := c.Labels(ctx) + if err != nil { + return err + } + + // Get datastore + dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) + if err != nil { + return err + } + + // Ensure we do have a stateDir label + stateDir := containerLabels[labels.StateDir] + if stateDir == "" { + stateDir, err = containerutil.ContainerStateDirPath(globalOptions.Namespace, dataStore, c.ID()) + if err != nil { + return err + } + } + + // Lock the container state + lf, err := containerutil.Lock(stateDir) + if err != nil { + return err + } + defer func() { + // If there was an error, update the label + // Note that we will (obviously) not store any unlocking or statedir removal error from below if retErr != nil { containerutil.UpdateErrorLabel(ctx, c, retErr) } + // Release the lock + retErr = errors.Join(lf.Release(), retErr) + // Note: technically, this is racy... + if retErr == nil { + retErr = os.RemoveAll(containerLabels[labels.StateDir]) + } }() - ns, err := namespaces.NamespaceRequired(ctx) + + // Get namespace + containerNamespace, err := namespaces.NamespaceRequired(ctx) if err != nil { return err } - id := c.ID() - l, err := c.Labels(ctx) + + // Get namestore + nameStore, err := namestore.New(dataStore, containerNamespace) if err != nil { return err } - stateDir := l[labels.StateDir] - name := l[labels.Name] - dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) + // Get volume store + volStore, err := volumestore.New(dataStore, globalOptions.Namespace) if err != nil { return err } - namst, err := namestore.New(dataStore, ns) + // Decode IPC + ipc, err := ipcutil.DecodeIPCLabel(containerLabels[labels.IPC]) if err != nil { return err } + // Get the container id and name + id := c.ID() + name := containerLabels[labels.Name] + + // This will evaluate retErr to decide if we proceed with removal or not defer func() { - if errdefs.IsNotFound(retErr) { - retErr = nil - } - if retErr != nil { + // If there was an error, and it was not "NotFound", this is a hard error, we stop here and do nothing. + if retErr != nil && !errdefs.IsNotFound(retErr) { return } - if err := os.RemoveAll(stateDir); err != nil { - logrus.WithError(retErr).Warnf("failed to remove container state dir %s", stateDir) - } - // enforce release name here in case the poststop hook name release fails - if name != "" { - if err := namst.Release(name, id); err != nil { - logrus.WithError(retErr).Warnf("failed to release container name %s", name) - } + + // Otherwise, nil the error so that we do not write the error label on the container + retErr = nil + + // Now, delete the actual container + var delOpts []containerd.DeleteOpts + if _, err := c.Image(ctx); err == nil { + delOpts = append(delOpts, containerd.WithSnapshotCleanup) } - if err := hostsstore.DeallocHostsFile(dataStore, ns, id); err != nil { - logrus.WithError(retErr).Warnf("failed to remove hosts file for container %q", id) + + spec, err := c.Spec(ctx) + if err != nil { + retErr = err + return } - }() - // volume removal is not handled by the poststop hook lifecycle because it depends on removeAnonVolumes option - if anonVolumesJSON, ok := l[labels.AnonymousVolumes]; ok && removeAnonVolumes { - var anonVolumes []string - if err := json.Unmarshal([]byte(anonVolumesJSON), &anonVolumes); err != nil { - return err + netOpts, err := containerutil.NetworkOptionsFromSpec(spec) + if err != nil { + retErr = fmt.Errorf("failed to load container networking options from specs: %s", err) + return } - volStore, err := volume.Store(globalOptions.Namespace, globalOptions.DataRoot, globalOptions.Address) + + networkManager, err := containerutil.NewNetworkingOptionsManager(globalOptions, netOpts, client) if err != nil { - return err + retErr = fmt.Errorf("failed to instantiate network options manager: %s", err) + return } - defer func() { - if _, err := volStore.Remove(anonVolumes); err != nil { - logrus.WithError(err).Warnf("failed to remove anonymous volumes %v", anonVolumes) - } - }() - } - task, err := c.Task(ctx, cio.Load) - if err != nil { - if errdefs.IsNotFound(err) { - if c.Delete(ctx, containerd.WithSnapshotCleanup) != nil { - return c.Delete(ctx) - } + if err := networkManager.CleanupNetworking(ctx, c); err != nil { + log.G(ctx).WithError(err).Warnf("failed to clean up container networking: %q", id) } - return err - } - status, err := task.Status(ctx) - if err != nil { - if errdefs.IsNotFound(err) { - return nil + // Delete the container now. If it fails, try again without snapshot cleanup + // If it still fails, time to stop. + if c.Delete(ctx, delOpts...) != nil { + retErr = c.Delete(ctx) + if retErr != nil { + return + } } - return err - } - // NOTE: on non-Windows platforms, network cleanup is performed by OCI hooks. - // Seeing as though Windows does not currently support OCI hooks, we must explicitly - // perform the network cleanup from the main nerdctl executable. - if runtime.GOOS == "windows" { - spec, err := c.Spec(ctx) - if err != nil { - return err + // Container has been removed successfully. Now we just finish the cleanup on our side. + + // Cleanup IPC - soft failure + if err = ipcutil.CleanUp(ipc); err != nil { + log.G(ctx).WithError(err).Warnf("failed to cleanup IPC for container %q", id) } - netOpts, err := containerutil.NetworkOptionsFromSpec(spec) - if err != nil { - return fmt.Errorf("failed to load container networking options from specs: %s", err) + // Enforce release name here in case the poststop hook name release fails - soft failure + if name != "" { + // Double-releasing may happen with containers started with --rm, so, ignore NotFound errors + if err := nameStore.Release(name, id); err != nil && !errors.Is(err, store.ErrNotFound) { + log.G(ctx).WithError(err).Warnf("failed to release container name %s", name) + } } - networkManager, err := containerutil.NewNetworkingOptionsManager(globalOptions, netOpts) + hs, err := hostsstore.New(dataStore, containerNamespace) if err != nil { - return fmt.Errorf("failed to instantiate network options manager: %s", err) + log.G(ctx).WithError(err).Warnf("failed to instantiate hostsstore for %q", containerNamespace) + } else if err = hs.Delete(id); err != nil { + // De-allocate hosts file - soft failure + log.G(ctx).WithError(err).Warnf("failed to remove hosts file for container %q", id) } - if err := networkManager.CleanupNetworking(ctx, c); err != nil { - logrus.WithError(retErr).Warnf("failed to clean up container networking: %s", err) + // Volume removal is not handled by the poststop hook lifecycle because it depends on removeAnonVolumes option + // Note that the anonymous volume list has been obtained earlier, without locking the volume store. + // Technically, a concurrent operation MAY have deleted these anonymous volumes already at this point, which + // would make this operation here "soft fail". + // This is not a problem per-se, though we will output a warning in that case. + if anonVolumesJSON, ok := containerLabels[labels.AnonymousVolumes]; ok && removeAnonVolumes { + var anonVolumes []string + if err = json.Unmarshal([]byte(anonVolumesJSON), &anonVolumes); err != nil { + log.G(ctx).WithError(err).Warnf("failed to unmarshall anonvolume information for container %q", id) + } else { + var errs []error + _, errs, err = volStore.Remove(func() ([]string, []error, error) { + return anonVolumes, nil, nil + }) + if err != nil || len(errs) > 0 { + log.G(ctx).WithError(err).Warnf("failed to remove anonymous volumes %v", anonVolumes) + } + } } + }() + + // Get the task. + task, err := c.Task(ctx, cio.Load) + if err != nil { + return err + } + + // Task was here, get the status + status, err := task.Status(ctx) + if err != nil { + return err } + // Now, we have a live task with a status. switch status.Status { - case containerd.Created, containerd.Stopped: - if _, err := task.Delete(ctx); err != nil && !errdefs.IsNotFound(err) { - return fmt.Errorf("failed to delete task %v: %w", id, err) - } case containerd.Paused: + // Paused containers only get removed if we force if !force { return NewStatusError(id, status.Status) } - _, err := task.Delete(ctx, containerd.WithProcessKill) - if err != nil && !errdefs.IsNotFound(err) { - return fmt.Errorf("failed to delete task %v: %w", id, err) - } - // default is the case, when status.Status = containerd.Running - default: + case containerd.Running: + // Running containers only get removed if we force if !force { return NewStatusError(id, status.Status) } - if err := task.Kill(ctx, syscall.SIGKILL); err != nil { - logrus.WithError(err).Warnf("failed to send SIGKILL") + // Kill the task. Soft error. + if err = task.Kill(ctx, syscall.SIGKILL); err != nil && !errdefs.IsNotFound(err) { + log.G(ctx).WithError(err).Warnf("failed to send SIGKILL to task %v", id) } es, err := task.Wait(ctx) if err == nil { <-es } - _, err = task.Delete(ctx, containerd.WithProcessKill) - if err != nil && !errdefs.IsNotFound(err) { - logrus.WithError(err).Warnf("failed to delete task %v", id) + case containerd.Created: + // TODO(Iceber): Since `containerd.WithProcessKill` blocks the killing of tasks with PID 0, + // remove the judgment and break when it is compatible with the tasks. + if task.Pid() == 0 { + // Created tasks with PID 0 always get removed + // Delete the task, without forcing kill + _, err = task.Delete(ctx) + return err } - } - var delOpts []containerd.DeleteOpts - if _, err := c.Image(ctx); err == nil { - delOpts = append(delOpts, containerd.WithSnapshotCleanup) - } - - if err := c.Delete(ctx, delOpts...); err != nil { + case containerd.Stopped: + // Stopped containers always get removed + // Delete the task, without forcing kill + _, err = task.Delete(ctx) return err + default: + // Unknown status error out + return fmt.Errorf("unknown container status %s", status.Status) } + + // Delete the task + _, err = task.Delete(ctx, containerd.WithProcessKill) return err } diff --git a/pkg/cmd/container/rename.go b/pkg/cmd/container/rename.go index 31b357e25e9..ca9ba6828e8 100644 --- a/pkg/cmd/container/rename.go +++ b/pkg/cmd/container/rename.go @@ -21,13 +21,15 @@ import ( "fmt" "runtime" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/dnsutil/hostsstore" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/namestore" + 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/clientutil" + "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/namestore" ) // Rename change container name to a new name @@ -42,7 +44,7 @@ func Rename(ctx context.Context, client *containerd.Client, containerID, newCont if err != nil { return err } - hostst, err := hostsstore.NewStore(dataStore) + hostst, err := hostsstore.New(dataStore, options.GOptions.Namespace) if err != nil { return err } @@ -66,24 +68,40 @@ func Rename(ctx context.Context, client *containerd.Client, containerID, newCont } func renameContainer(ctx context.Context, container containerd.Container, newName, ns string, - namst namestore.NameStore, hostst hostsstore.Store) error { + namst namestore.NameStore, hostst hostsstore.Store) (err error) { l, err := container.Labels(ctx) if err != nil { return err } name := l[labels.Name] - if err := namst.Rename(name, container.ID(), newName); err != nil { + + id := container.ID() + + defer func() { + // If we errored, rollback whatever we can + if err != nil { + lbls := map[string]string{ + labels.Name: name, + } + namst.Rename(newName, id, name) + hostst.Update(id, name) + container.SetLabels(ctx, lbls) + } + }() + + if err = namst.Rename(name, id, newName); err != nil { return err } if runtime.GOOS == "linux" { - if err := hostst.Update(ns, container.ID(), newName); err != nil { - return err + if err = hostst.Update(id, newName); err != nil { + log.G(ctx).WithError(err).Warn("failed to update host networking definitions " + + "- if your container is using network 'none', this is expected - otherwise, please report this as a bug") } } - labels := map[string]string{ + lbls := map[string]string{ labels.Name: newName, } - if _, err = container.SetLabels(ctx, labels); err != nil { + if _, err = container.SetLabels(ctx, lbls); err != nil { return err } return nil diff --git a/pkg/cmd/container/restart.go b/pkg/cmd/container/restart.go index 068a11a8001..2be386ea323 100644 --- a/pkg/cmd/container/restart.go +++ b/pkg/cmd/container/restart.go @@ -20,10 +20,11 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Restart will restart one or more containers. @@ -40,7 +41,7 @@ func Restart(ctx context.Context, client *containerd.Client, containers []string if err := containerutil.Start(ctx, found.Container, false, client, ""); err != nil { return err } - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(options.Stdout, found.Req) return err }, } diff --git a/pkg/cmd/container/run_cgroup_linux.go b/pkg/cmd/container/run_cgroup_linux.go index 0bc34f1e94a..af43b12c1fd 100644 --- a/pkg/cmd/container/run_cgroup_linux.go +++ b/pkg/cmd/container/run_cgroup_linux.go @@ -23,14 +23,16 @@ import ( "path/filepath" "strings" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" "github.com/docker/go-units" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) type customMemoryOptions struct { @@ -41,11 +43,11 @@ type customMemoryOptions struct { func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci.SpecOpts, error) { if options.KernelMemory != "" { - logrus.Warnf("The --kernel-memory flag is no longer supported. This flag is a noop.") + log.L.Warnf("The --kernel-memory flag is no longer supported. This flag is a noop.") } if options.Memory == "" && options.OomKillDisable { - logrus.Warn("Disabling the OOM killer on containers without setting a '-m/--memory' limit may be dangerous.") + log.L.Warn("Disabling the OOM killer on containers without setting a '-m/--memory' limit may be dangerous.") } if options.GOptions.CgroupManager == "none" { @@ -54,11 +56,11 @@ func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci. } if options.CPUs > 0.0 || options.Memory != "" || options.MemorySwap != "" || options.PidsLimit > 0 { - logrus.Warn(`cgroup manager is set to "none", discarding resource limit requests. ` + + log.L.Warn(`cgroup manager is set to "none", discarding resource limit requests. ` + "(Hint: enable cgroup v2 with systemd: https://rootlesscontaine.rs/getting-started/common/cgroup2/)") } if options.CgroupParent != "" { - logrus.Warnf(`cgroup manager is set to "none", ignoring cgroup parent %q`+ + log.L.Warnf(`cgroup manager is set to "none", ignoring cgroup parent %q`+ "(Hint: enable cgroup v2 with systemd: https://rootlesscontaine.rs/getting-started/common/cgroup2/)", options.CgroupParent) } return []oci.SpecOpts{oci.WithCgroup("")}, nil @@ -178,7 +180,7 @@ func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci. opts = append(opts, withUnified(unifieds)) if options.BlkioWeight != 0 && !infoutil.BlockIOWeight(options.GOptions.CgroupManager) { - logrus.Warn("kernel support for cgroup blkio weight missing, weight discarded") + log.L.Warn("kernel support for cgroup blkio weight missing, weight discarded") options.BlkioWeight = 0 } if options.BlkioWeight > 0 && options.BlkioWeight < 10 || options.BlkioWeight > 1000 { @@ -199,12 +201,13 @@ func generateCgroupOpts(id string, options types.ContainerCreateOptions) ([]oci. } for _, f := range options.Device { - devPath, mode, err := ParseDevice(f) + devPath, conPath, mode, err := ParseDevice(f) if err != nil { return nil, fmt.Errorf("failed to parse device %q: %w", f, err) } - opts = append(opts, oci.WithLinuxDevice(devPath, mode)) + opts = append(opts, oci.WithDevices(devPath, conPath, mode)) } + return opts, nil } @@ -246,8 +249,8 @@ func generateCgroupPath(id, cgroupManager, cgroupParent string) (string, error) return path, nil } -// ParseDevice parses the give device string into hostDevPath and mode(defaults: "rwm"). -func ParseDevice(s string) (hostDevPath string, mode string, err error) { +// ParseDevice parses the give device string into hostDevPath, containerPath and mode(defaults: "rwm"). +func ParseDevice(s string) (hostDevPath string, containerPath string, mode string, err error) { mode = "rwm" split := strings.Split(s, ":") var containerDevPath string @@ -268,21 +271,17 @@ func ParseDevice(s string) (hostDevPath string, mode string, err error) { containerDevPath = split[1] mode = split[2] default: - return "", "", errors.New("too many `:` symbols") - } - - if containerDevPath != hostDevPath { - return "", "", errors.New("changing the path inside the container is not supported yet") + return "", "", "", errors.New("too many `:` symbols") } if !filepath.IsAbs(hostDevPath) { - return "", "", fmt.Errorf("%q is not an absolute path", hostDevPath) + return "", "", "", fmt.Errorf("%q is not an absolute path", hostDevPath) } if err := validateDeviceMode(mode); err != nil { - return "", "", err + return "", "", "", err } - return hostDevPath, mode, nil + return hostDevPath, containerDevPath, mode, nil } func validateDeviceMode(mode string) error { diff --git a/pkg/cmd/container/run_freebsd.go b/pkg/cmd/container/run_freebsd.go index 5aabb440848..ddf743121e7 100644 --- a/pkg/cmd/container/run_freebsd.go +++ b/pkg/cmd/container/run_freebsd.go @@ -19,10 +19,11 @@ package container import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) func WithoutRunMount() func(ctx context.Context, client oci.Client, c *containers.Container, s *oci.Spec) error { diff --git a/pkg/cmd/container/run_gpus.go b/pkg/cmd/container/run_gpus.go index cd316fcb00c..967858b6bf5 100644 --- a/pkg/cmd/container/run_gpus.go +++ b/pkg/cmd/container/run_gpus.go @@ -22,10 +22,6 @@ import ( "fmt" "strconv" "strings" - - "github.com/containerd/containerd/contrib/nvidia" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/rootlessutil" ) // GPUReq is a request for GPUs. @@ -35,64 +31,6 @@ type GPUReq struct { Capabilities []string } -func parseGPUOpts(value []string) (res []oci.SpecOpts, _ error) { - for _, gpu := range value { - gpuOpt, err := parseGPUOpt(gpu) - if err != nil { - return nil, err - } - res = append(res, gpuOpt) - } - return res, nil -} - -func parseGPUOpt(value string) (oci.SpecOpts, error) { - req, err := ParseGPUOptCSV(value) - if err != nil { - return nil, err - } - - var gpuOpts []nvidia.Opts - - 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) - } - - 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 cap, isNvidiaCap := str2cap[c]; isNvidiaCap { - nvidiaCaps = append(nvidiaCaps, cap) - } - } - 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) - } - - return nvidia.WithGPUs(gpuOpts...), nil -} - // ParseGPUOptCSV parses a GPU option from CSV. func ParseGPUOptCSV(value string) (*GPUReq, error) { csvReader := csv.NewReader(strings.NewReader(value)) diff --git a/pkg/cmd/container/run_linux.go b/pkg/cmd/container/run_linux.go index cb90dbf694a..cbe38d62e46 100644 --- a/pkg/cmd/container/run_linux.go +++ b/pkg/cmd/container/run_linux.go @@ -21,19 +21,22 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/userns" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/bypass4netnsutil" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/docker/go-units" + "github.com/moby/sys/userns" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + 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" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/bypass4netnsutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/ipcutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // WithoutRunMount returns a SpecOpts that unmounts the default tmpfs on "/run" @@ -59,10 +62,7 @@ func setPlatformOptions(ctx context.Context, client *containerd.Client, id, uts } opts = append(opts, cgOpts...) - labelsMap, err := readKVStringsMapfFromLabel(options.Label, options.LabelFile) - if err != nil { - return nil, err - } + annotations := strutil.ConvertKVStringsToMap(options.Annotations) capOpts, err := generateCapOpts( strutil.DedupeStrSlice(options.CapAdd), @@ -78,23 +78,23 @@ func setPlatformOptions(ctx context.Context, client *containerd.Client, id, uts } opts = append(opts, secOpts...) - b4nnOpts, err := bypass4netnsutil.GenerateBypass4netnsOpts(securityOptsMaps, labelsMap, id) + b4nnOpts, err := bypass4netnsutil.GenerateBypass4netnsOpts(securityOptsMaps, annotations, id) if err != nil { return nil, err } opts = append(opts, b4nnOpts...) - if len(options.ShmSize) > 0 { - shmBytes, err := units.RAMInBytes(options.ShmSize) - if err != nil { - return nil, err - } - opts = append(opts, oci.WithDevShmSize(shmBytes/1024)) - } ulimitOpts, err := generateUlimitsOpts(options.Ulimit) if err != nil { return nil, err } + + // If without any ulimitOpts, we need to reset the default value from spec + // which has 1024 as file limit. Make this behavior same as containerd/cri. + if len(ulimitOpts) == 0 { + ulimitOpts = append(ulimitOpts, withRlimits(nil)) + } + opts = append(opts, ulimitOpts...) if options.Sysctl != nil { opts = append(opts, WithSysctls(strutil.ConvertKVStringsToMap(options.Sysctl))) @@ -142,15 +142,13 @@ func generateNamespaceOpts( return nil, fmt.Errorf("unknown uts value. valid value(s) are 'host', got: %q", uts) } - switch options.IPC { - case "host": - opts = append(opts, oci.WithHostNamespace(specs.IPCNamespace)) - opts = append(opts, withBindMountHostIPC) - case "private", "": - // If nothing is specified, or if private, default to normal behavior - default: - return nil, fmt.Errorf("unknown ipc value. valid values are 'private' or 'host', got: %q", options.IPC) + stateDir := internalLabels.stateDir + ipcOpts, ipcLabel, err := generateIPCOpts(ctx, client, options.IPC, options.ShmSize, stateDir) + if err != nil { + return nil, err } + internalLabels.ipc = ipcLabel + opts = append(opts, ipcOpts...) pidOpts, pidLabel, err := generatePIDOpts(ctx, client, options.Pid) if err != nil { @@ -162,6 +160,25 @@ func generateNamespaceOpts( return opts, nil } +func generateIPCOpts(ctx context.Context, client *containerd.Client, ipcFlag string, shmSize string, stateDir string) ([]oci.SpecOpts, string, error) { + ipcFlag = strings.ToLower(ipcFlag) + + ipc, err := ipcutil.DetectFlags(ctx, client, stateDir, ipcFlag, shmSize) + if err != nil { + return nil, "", err + } + ipcLabel, err := ipcutil.EncodeIPCLabel(ipc) + if err != nil { + return nil, "", err + } + opts, err := ipcutil.GenerateIPCOpts(ctx, ipc, client) + if err != nil { + return nil, "", err + } + + return opts, ipcLabel, nil +} + func generatePIDOpts(ctx context.Context, client *containerd.Client, pid string) ([]oci.SpecOpts, string, error) { opts := make([]oci.SpecOpts, 0) pid = strings.ToLower(pid) @@ -229,7 +246,7 @@ func setOOMScoreAdj(opts []oci.SpecOpts, oomScoreAdjChanged bool, oomScoreAdj in // (FIXME: find a more robust way to get the current minimum value) const minimum = 100 if oomScoreAdj < minimum { - logrus.Warnf("Limiting oom_score_adj (%d -> %d)", oomScoreAdj, minimum) + log.L.Warnf("Limiting oom_score_adj (%d -> %d)", oomScoreAdj, minimum) oomScoreAdj = minimum } } @@ -244,3 +261,61 @@ func withOOMScoreAdj(score int) oci.SpecOpts { return nil } } + +func parseGPUOpts(value []string) (res []oci.SpecOpts, _ error) { + for _, gpu := range value { + gpuOpt, err := parseGPUOpt(gpu) + if err != nil { + return nil, err + } + res = append(res, gpuOpt) + } + return res, nil +} + +func parseGPUOpt(value string) (oci.SpecOpts, error) { + req, err := ParseGPUOptCSV(value) + if err != nil { + return nil, err + } + + var gpuOpts []nvidia.Opts + + 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) + } + + 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 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) + } + + return nvidia.WithGPUs(gpuOpts...), nil +} diff --git a/pkg/cmd/container/run_mount.go b/pkg/cmd/container/run_mount.go index 9bfd6540399..eb9be3aefb2 100644 --- a/pkg/cmd/container/run_mount.go +++ b/pkg/cmd/container/run_mount.go @@ -18,31 +18,38 @@ package container import ( "context" + "encoding/json" + "errors" "fmt" "os" "path/filepath" "runtime" "sort" "strings" + "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/mount" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/userns" - "github.com/containerd/continuity/fs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/volume" - "github.com/containerd/nerdctl/pkg/idgen" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/mountutil" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" - "github.com/containerd/nerdctl/pkg/strutil" securejoin "github.com/cyphar/filepath-securejoin" + "github.com/moby/sys/userns" "github.com/opencontainers/image-spec/identity" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/core/leases" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/continuity/fs" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idgen" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/mountutil" + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // copy from https://github.com/containerd/containerd/blob/v1.6.0-rc.1/pkg/cri/opts/spec_linux.go#L129-L151 @@ -88,7 +95,8 @@ func withMounts(mounts []specs.Mount) oci.SpecOpts { func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCreateOptions) ([]*mountutil.Processed, error) { var parsed []*mountutil.Processed //nolint:prealloc for _, v := range strutil.DedupeStrSlice(options.Volume) { - x, err := mountutil.ProcessFlagV(v, volStore) + // createDir=true for -v option to allow creation of directory on host if not found. + x, err := mountutil.ProcessFlagV(v, volStore, true) if err != nil { return nil, err } @@ -116,13 +124,8 @@ func parseMountFlags(volStore volumestore.VolumeStore, options types.ContainerCr // generateMountOpts generates volume-related mount opts. // Other mounts such as procfs mount are not handled here. -func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredImage *imgutil.EnsuredImage, options types.ContainerCreateOptions) ([]oci.SpecOpts, []string, []*mountutil.Processed, error) { - // volume store is corresponds to a directory like `/var/lib/nerdctl/1935db59/volumes/default` - volStore, err := volume.Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) - if err != nil { - return nil, nil, nil, err - } - +func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredImage *imgutil.EnsuredImage, + volStore volumestore.VolumeStore, options types.ContainerCreateOptions) ([]oci.SpecOpts, []string, []*mountutil.Processed, error) { //nolint:golint,prealloc var ( opts []oci.SpecOpts @@ -156,6 +159,14 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm // When the Unmount fails, RemoveAll will incorrectly delete data from the mounted dir defer os.Remove(tempDir) + // Add a lease of 1 hour to the view so that it is not garbage collected + // Note(gsamfira): should we make this shorter? + ctx, done, err := client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour)) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create lease: %w", err) + } + defer done(ctx) + var mounts []mount.Mount mounts, err = s.View(ctx, tempDir, chainID) if err != nil { @@ -166,26 +177,14 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm // https://github.com/containerd/containerd/commit/791e175c79930a34cfbb2048fbcaa8493fd2c86b unmounter := func(mountPath string) { if uerr := mount.Unmount(mountPath, 0); uerr != nil { - logrus.Debugf("Failed to unmount snapshot %q", tempDir) + log.G(ctx).Debugf("Failed to unmount snapshot %q", tempDir) if err == nil { err = uerr } } } - if runtime.GOOS == "windows" { - for _, m := range mounts { - defer unmounter(m.Source) - // appending the layerID to the root. - mountPath := filepath.Join(tempDir, filepath.Base(m.Source)) - if err := m.Mount(mountPath); err != nil { - if err := s.Remove(ctx, tempDir); err != nil && !errdefs.IsNotFound(err) { - return nil, nil, nil, err - } - return nil, nil, nil, err - } - } - } else if runtime.GOOS == "linux" { + if runtime.GOOS == "linux" { defer unmounter(tempDir) for _, m := range mounts { m := m @@ -257,9 +256,9 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm } anonVolName := idgen.GenerateID() - logrus.Debugf("creating anonymous volume %q, for \"VOLUME %s\"", + log.G(ctx).Debugf("creating anonymous volume %q, for \"VOLUME %s\"", anonVolName, imgVolRaw) - anonVol, err := volStore.Create(anonVolName, []string{}) + anonVol, err := volStore.CreateWithoutLock(anonVolName, []string{}) if err != nil { return nil, nil, nil, err } @@ -292,6 +291,58 @@ func generateMountOpts(ctx context.Context, client *containerd.Client, ensuredIm } opts = append(opts, withMounts(userMounts)) + + containers, err := client.Containers(ctx) + if err != nil { + return nil, nil, nil, err + } + + vfSet := strutil.SliceToSet(options.VolumesFrom) + var vfMountPoints []dockercompat.MountPoint + var vfAnonVolumes []string + + for _, c := range containers { + ls, err := c.Labels(ctx) + if err != nil { + // Containerd note: there is no guarantee that the containers we got from the list still exist at this point + // If that is the case, just ignore and move on + if errors.Is(err, errdefs.ErrNotFound) { + log.G(ctx).Debugf("container %q is gone - ignoring", c.ID()) + continue + } + return nil, nil, nil, err + } + _, idMatch := vfSet[c.ID()] + nameMatch := false + if name, found := ls[labels.Name]; found { + _, nameMatch = vfSet[name] + } + + if idMatch || nameMatch { + if av, found := ls[labels.AnonymousVolumes]; found { + err = json.Unmarshal([]byte(av), &vfAnonVolumes) + if err != nil { + return nil, nil, nil, err + } + } + if m, found := ls[labels.Mounts]; found { + err = json.Unmarshal([]byte(m), &vfMountPoints) + if err != nil { + return nil, nil, nil, err + } + } + + ps := processeds(vfMountPoints) + s, err := c.Spec(ctx) + if err != nil { + return nil, nil, nil, err + } + opts = append(opts, withMounts(s.Mounts)) + anonVolumes = append(anonVolumes, vfAnonVolumes...) + mountPoints = append(mountPoints, ps...) + } + } + return opts, anonVolumes, mountPoints, nil } @@ -306,7 +357,7 @@ func copyExistingContents(source, destination string) error { return err } if len(dstList) != 0 { - logrus.Debugf("volume at %q is not initially empty, skipping copying", destination) + log.L.Debugf("volume at %q is not initially empty, skipping copying", destination) return nil } return fs.CopyDir(destination, source) diff --git a/pkg/cmd/container/run_restart.go b/pkg/cmd/container/run_restart.go index 81c190133c6..5932f2175d5 100644 --- a/pkg/cmd/container/run_restart.go +++ b/pkg/cmd/container/run_restart.go @@ -21,9 +21,10 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/runtime/restart" - "github.com/containerd/nerdctl/pkg/strutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/runtime/restart" + + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func checkRestartCapabilities(ctx context.Context, client *containerd.Client, restartFlag string) (bool, error) { @@ -32,7 +33,7 @@ func checkRestartCapabilities(ctx context.Context, client *containerd.Client, re case "", "no": return true, nil } - res, err := client.IntrospectionService().Plugins(ctx, []string{"id==restart"}) + res, err := client.IntrospectionService().Plugins(ctx, "id==restart") if err != nil { return false, err } diff --git a/pkg/cmd/container/run_runtime.go b/pkg/cmd/container/run_runtime.go index 20a121d5dad..b6d0ac4965e 100644 --- a/pkg/cmd/container/run_runtime.go +++ b/pkg/cmd/container/run_runtime.go @@ -20,17 +20,18 @@ import ( "context" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/plugin" - runcoptions "github.com/containerd/containerd/runtime/v2/runc/options" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + runcoptions "github.com/containerd/containerd/api/types/runc/options" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/containerd/v2/plugins" + "github.com/containerd/log" ) func generateRuntimeCOpts(cgroupManager, runtimeStr string) ([]containerd.NewContainerOpts, error) { - runtime := plugin.RuntimeRuncV2 + runtime := plugins.RuntimeRuncV2 var ( runcOpts runcoptions.Options runtimeOpts interface{} = &runcOpts @@ -43,7 +44,7 @@ func generateRuntimeCOpts(cgroupManager, runtimeStr string) ([]containerd.NewCon runtime = runtimeStr if !strings.HasPrefix(runtimeStr, "io.containerd.runc.") { if cgroupManager == "systemd" { - logrus.Warnf("cannot set cgroup manager to %q for runtime %q", cgroupManager, runtimeStr) + log.L.Warnf("cannot set cgroup manager to %q for runtime %q", cgroupManager, runtimeStr) } runtimeOpts = nil } diff --git a/pkg/cmd/container/run_security_linux.go b/pkg/cmd/container/run_security_linux.go index a325c91b87c..510310f265a 100644 --- a/pkg/cmd/container/run_security_linux.go +++ b/pkg/cmd/container/run_security_linux.go @@ -21,15 +21,16 @@ import ( "strings" "sync" - "github.com/containerd/containerd/contrib/apparmor" - "github.com/containerd/containerd/contrib/seccomp" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/cap" - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/maputil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/contrib/apparmor" + "github.com/containerd/containerd/v2/contrib/seccomp" + "github.com/containerd/containerd/v2/pkg/cap" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/maputil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) var privilegedOpts = []oci.SpecOpts{ @@ -44,16 +45,20 @@ var privilegedWithoutDevicesOpts = []oci.SpecOpts{ oci.WithNewPrivileges, } +const ( + systemPathsUnconfined = "unconfined" +) + func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([]oci.SpecOpts, error) { for k := range securityOptsMap { switch k { - case "seccomp", "apparmor", "no-new-privileges", "privileged-without-host-devices": + case "seccomp", "apparmor", "no-new-privileges", "systempaths", "privileged-without-host-devices": default: - logrus.Warnf("unknown security-opt: %q", k) + log.L.Warnf("unknown security-opt: %q", k) } } var opts []oci.SpecOpts - if seccompProfile, ok := securityOptsMap["seccomp"]; ok { + if seccompProfile, ok := securityOptsMap["seccomp"]; ok && seccompProfile != defaults.SeccompProfileName { if seccompProfile == "" { return nil, errors.New("invalid security-opt \"seccomp\"") } @@ -73,7 +78,7 @@ func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([ } if aaProfile != "unconfined" { if !canApplyExistingProfile { - logrus.Warnf("the host does not support AppArmor. Ignoring profile %q", aaProfile) + log.L.Warnf("the host does not support AppArmor. Ignoring profile %q", aaProfile) } else { opts = append(opts, apparmor.WithProfile(aaProfile)) } @@ -98,6 +103,13 @@ func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([ opts = append(opts, oci.WithNewPrivileges) } + if value, ok := securityOptsMap["systempaths"]; ok && value == systemPathsUnconfined { + opts = append(opts, oci.WithMaskedPaths(nil)) + opts = append(opts, oci.WithReadonlyPaths(nil)) + } else if ok && value != systemPathsUnconfined { + return nil, errors.New(`invalid security-opt "systempaths=unconfined"`) + } + privilegedWithoutHostDevices, err := maputil.MapBoolValueAsOpt(securityOptsMap, "privileged-without-host-devices") if err != nil { return nil, err @@ -127,7 +139,7 @@ func canonicalizeCapName(s string) string { s = "CAP_" + s } if !isKnownCapName(s) { - logrus.Warnf("unknown capability name %q", s) + log.L.Warnf("unknown capability name %q", s) // Not a fatal error, because runtime might be aware of this cap } return s diff --git a/pkg/cmd/container/run_ulimit.go b/pkg/cmd/container/run_ulimit_linux.go similarity index 90% rename from pkg/cmd/container/run_ulimit.go rename to pkg/cmd/container/run_ulimit_linux.go index 52fec1f8ba6..b19c31a35dc 100644 --- a/pkg/cmd/container/run_ulimit.go +++ b/pkg/cmd/container/run_ulimit_linux.go @@ -20,11 +20,13 @@ import ( "context" "strings" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/docker/go-units" "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func generateUlimitsOpts(ulimits []string) ([]oci.SpecOpts, error) { diff --git a/pkg/cmd/container/run_user.go b/pkg/cmd/container/run_user.go index 7bc9324694b..f03342897e5 100644 --- a/pkg/cmd/container/run_user.go +++ b/pkg/cmd/container/run_user.go @@ -21,8 +21,8 @@ import ( "fmt" "strconv" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" ) func generateUserOpts(user string) ([]oci.SpecOpts, error) { diff --git a/pkg/cmd/container/run_windows.go b/pkg/cmd/container/run_windows.go index 6d6e5a32184..6b38e7e9f3f 100644 --- a/pkg/cmd/container/run_windows.go +++ b/pkg/cmd/container/run_windows.go @@ -22,12 +22,14 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" "github.com/docker/go-units" "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) const ( @@ -47,7 +49,7 @@ func setPlatformOptions( internalLabels *internalLabels, options types.ContainerCreateOptions, ) ([]oci.SpecOpts, error) { - var opts []oci.SpecOpts + opts := []oci.SpecOpts{} if options.CPUs > 0.0 { opts = append(opts, oci.WithWindowsCPUCount(uint64(options.CPUs))) } diff --git a/pkg/cmd/container/start.go b/pkg/cmd/container/start.go index c4b98e8d09b..3d9de68cb29 100644 --- a/pkg/cmd/container/start.go +++ b/pkg/cmd/container/start.go @@ -20,10 +20,11 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Start starts a list of `containers`. If attach is true, it only starts a single container. @@ -43,7 +44,7 @@ func Start(ctx context.Context, client *containerd.Client, reqs []string, option return err } if !options.Attach { - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(options.Stdout, found.Req) if err != nil { return err } diff --git a/cmd/nerdctl/container_stats.go b/pkg/cmd/container/stats.go similarity index 69% rename from cmd/nerdctl/container_stats.go rename to pkg/cmd/container/stats.go index 63fc5b37515..b21c08fce61 100644 --- a/cmd/nerdctl/container_stats.go +++ b/pkg/cmd/container/stats.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "bytes" @@ -27,46 +27,24 @@ import ( "text/template" "time" - "github.com/containerd/containerd" eventstypes "github.com/containerd/containerd/api/events" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/events" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/containerinspector" - "github.com/containerd/nerdctl/pkg/eventutil" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/statsutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/events" + "github.com/containerd/errdefs" + "github.com/containerd/log" "github.com/containerd/typeurl/v2" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newStatsCommand() *cobra.Command { - var statsCommand = &cobra.Command{ - Use: "stats", - Short: "Display a live stream of container(s) resource usage statistics.", - RunE: statsAction, - ValidArgsFunction: statsShellComplete, - SilenceUsage: true, - SilenceErrors: true, - } - - addStatsFlags(statsCommand) - return statsCommand -} - -func addStatsFlags(cmd *cobra.Command) { - cmd.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") - cmd.Flags().String("format", "", "Pretty-print images using a Go template, e.g, '{{json .}}'") - cmd.Flags().Bool("no-stream", false, "Disable streaming stats and only pull the first result") - cmd.Flags().Bool("no-trunc", false, "Do not truncate output") -} + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/containerinspector" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/eventutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/infoutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/statsutil" +) type stats struct { mu sync.Mutex @@ -77,7 +55,7 @@ type stats struct { func (s *stats) add(cs *statsutil.Stats) bool { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.isKnownContainer(cs.Container); !exists { + if _, exists := s.isKnownContainer(cs.ID); !exists { s.cs = append(s.cs, cs) return true } @@ -96,69 +74,42 @@ func (s *stats) remove(id string) { // isKnownContainer is from https://github.com/docker/cli/blob/3fb4fb83dfb5db0c0753a8316f21aea54dab32c5/cli/command/container/stats_helpers.go#L44-L51 func (s *stats) isKnownContainer(cid string) (int, bool) { for i, c := range s.cs { - if c.Container == cid { + if c.ID == cid { return i, true } } return -1, false } -func statsAction(cmd *cobra.Command, args []string) error { - +// Stats displays a live stream of container(s) resource usage statistics. +func Stats(ctx context.Context, client *containerd.Client, containerIDs []string, options types.ContainerStatsOptions) error { // NOTE: rootless container does not rely on cgroupv1. // more details about possible ways to resolve this concern: #223 - globalOptions, err := processRootCmdFlags(cmd) - if err != nil { - return err - } if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { return errors.New("stats requires cgroup v2 for rootless containers, see https://rootlesscontaine.rs/getting-started/common/cgroup2/") } - showAll := len(args) == 0 + showAll := len(containerIDs) == 0 closeChan := make(chan error) - all, err := cmd.Flags().GetBool("all") - if err != nil { - return err - } - - noStream, err := cmd.Flags().GetBool("no-stream") - if err != nil { - return err - } - - format, err := cmd.Flags().GetString("format") - if err != nil { - return err - } - var w = cmd.OutOrStdout() + var err error + var w = options.Stdout var tmpl *template.Template - switch format { + switch options.Format { case "", "table": - w = tabwriter.NewWriter(cmd.OutOrStdout(), 10, 1, 3, ' ', 0) + w = tabwriter.NewWriter(options.Stdout, 10, 1, 3, ' ', 0) case "raw": return errors.New("unsupported format: \"raw\"") default: - tmpl, err = formatter.ParseTemplate(format) + tmpl, err = formatter.ParseTemplate(options.Format) if err != nil { return err } } - noTrunc, err := cmd.Flags().GetBool("no-trunc") - if err != nil { - return err - } - // waitFirst is a WaitGroup to wait first stat data's reach for each container waitFirst := &sync.WaitGroup{} cStats := stats{} - client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) - if err != nil { - return err - } - defer cancel() monitorContainerEvents := func(started chan<- struct{}, c chan *events.Envelope) { eventsClient := client.EventService() @@ -189,7 +140,7 @@ func statsAction(cmd *cobra.Command, args []string) error { for _, c := range containers { cStatus := formatter.ContainerStatus(ctx, c) - if !all { + if !options.All { if !strings.HasPrefix(cStatus, "Up") { continue } @@ -197,7 +148,7 @@ func statsAction(cmd *cobra.Command, args []string) error { s := statsutil.NewStats(c.ID()) if cStats.add(s) { waitFirst.Add(1) - go collect(cmd, globalOptions, s, waitFirst, c.ID(), !noStream) + go collect(ctx, options.GOptions, s, waitFirst, c.ID(), !options.NoStream) } } } @@ -228,7 +179,7 @@ func statsAction(cmd *cobra.Command, args []string) error { s := statsutil.NewStats(datacc.ID) if cStats.add(s) { waitFirst.Add(1) - go collect(cmd, globalOptions, s, waitFirst, datacc.ID, !noStream) + go collect(ctx, options.GOptions, s, waitFirst, datacc.ID, !options.NoStream) } }) @@ -271,13 +222,13 @@ func statsAction(cmd *cobra.Command, args []string) error { s := statsutil.NewStats(found.Container.ID()) if cStats.add(s) { waitFirst.Add(1) - go collect(cmd, globalOptions, s, waitFirst, found.Container.ID(), !noStream) + go collect(ctx, options.GOptions, s, waitFirst, found.Container.ID(), !options.NoStream) } return nil }, } - if err := walker.WalkAll(ctx, args, false); err != nil { + if err := walker.WalkAll(ctx, containerIDs, false); err != nil { return err } @@ -287,9 +238,9 @@ func statsAction(cmd *cobra.Command, args []string) error { } cleanScreen := func() { - if !noStream { - fmt.Fprint(cmd.OutOrStdout(), "\033[2J") - fmt.Fprint(cmd.OutOrStdout(), "\033[H") + if !options.NoStream { + fmt.Fprint(options.Stdout, "\033[2J") + fmt.Fprint(options.Stdout, "\033[H") } } @@ -305,7 +256,7 @@ func statsAction(cmd *cobra.Command, args []string) error { cStats.mu.Lock() for _, c := range cStats.cs { if err := c.GetError(); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "unable to get stat entry: %s\n", err) + fmt.Fprintf(options.Stderr, "unable to get stat entry: %s\n", err) } ccstats = append(ccstats, c.GetStatistics()) } @@ -313,7 +264,7 @@ func statsAction(cmd *cobra.Command, args []string) error { if !firstTick { // print header for every tick - if format == "" || format == "table" { + if options.Format == "" || options.Format == "table" { fmt.Fprintln(w, "CONTAINER ID\tNAME\tCPU %\tMEM USAGE / LIMIT\tMEM %\tNET I/O\tBLOCK I/O\tPIDS") } } @@ -322,14 +273,14 @@ func statsAction(cmd *cobra.Command, args []string) error { if c.ID == "" { continue } - rc := statsutil.RenderEntry(&c, noTrunc) + rc := statsutil.RenderEntry(&c, options.NoTrunc) if !firstTick { if tmpl != nil { var b bytes.Buffer if err := tmpl.Execute(&b, rc); err != nil { break } - if _, err = fmt.Fprintln(cmd.OutOrStdout(), b.String()); err != nil { + if _, err = fmt.Fprintln(options.Stdout, b.String()); err != nil { break } } else { @@ -355,7 +306,7 @@ func statsAction(cmd *cobra.Command, args []string) error { if len(cStats.cs) == 0 && !showAll { break } - if noStream && !firstTick { + if options.NoStream && !firstTick { break } select { @@ -374,8 +325,8 @@ func statsAction(cmd *cobra.Command, args []string) error { return err } -func collect(cmd *cobra.Command, globalOptions types.GlobalCommandOptions, s *statsutil.Stats, waitFirst *sync.WaitGroup, id string, noStream bool) { - logrus.Debugf("collecting stats for %s", s.Container) +func collect(ctx context.Context, globalOptions types.GlobalCommandOptions, s *statsutil.Stats, waitFirst *sync.WaitGroup, id string, noStream bool) { + log.G(ctx).Debugf("collecting stats for %s", s.ID) var ( getFirst = true u = make(chan error, 1) @@ -388,12 +339,15 @@ func collect(cmd *cobra.Command, globalOptions types.GlobalCommandOptions, s *st waitFirst.Done() } }() - client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) + client, ctx, cancel, err := clientutil.NewClient(ctx, globalOptions.Namespace, globalOptions.Address) if err != nil { s.SetError(err) return } - defer cancel() + defer func() { + cancel() + client.Close() + }() container, err := client.LoadContainer(ctx, id) if err != nil { s.SetError(err) @@ -441,7 +395,7 @@ func collect(cmd *cobra.Command, globalOptions types.GlobalCommandOptions, s *st u <- err continue } - statsEntry.Name = clabels[labels.Name] + statsEntry.Name = containerutil.GetContainerName(clabels) statsEntry.ID = container.ID() if firstSet { @@ -480,11 +434,3 @@ func collect(cmd *cobra.Command, globalOptions types.GlobalCommandOptions, s *st } } } - -func statsShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // show running container names - statusFilterFn := func(st containerd.ProcessStatus) bool { - return st == containerd.Running - } - return shellCompleteContainerNames(cmd, statusFilterFn) -} diff --git a/cmd/nerdctl/container_stats_freebsd.go b/pkg/cmd/container/stats_freebsd.go similarity index 86% rename from cmd/nerdctl/container_stats_freebsd.go rename to pkg/cmd/container/stats_freebsd.go index a6460b3218a..ef2c98fdfad 100644 --- a/cmd/nerdctl/container_stats_freebsd.go +++ b/pkg/cmd/container/stats_freebsd.go @@ -14,11 +14,11 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/statsutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/statsutil" ) func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface) (statsutil.StatsEntry, error) { diff --git a/cmd/nerdctl/container_stats_linux.go b/pkg/cmd/container/stats_linux.go similarity index 95% rename from cmd/nerdctl/container_stats_linux.go rename to pkg/cmd/container/stats_linux.go index a8d29deec3f..76aa1c96ab3 100644 --- a/cmd/nerdctl/container_stats_linux.go +++ b/pkg/cmd/container/stats_linux.go @@ -14,7 +14,7 @@ limitations under the License. */ -package main +package container import ( "errors" @@ -23,12 +23,14 @@ import ( "strings" "time" - v1 "github.com/containerd/cgroups/v3/cgroup1/stats" - v2 "github.com/containerd/cgroups/v3/cgroup2/stats" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/statsutil" "github.com/vishvananda/netlink" "github.com/vishvananda/netns" + + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" + v2 "github.com/containerd/cgroups/v3/cgroup2/stats" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/statsutil" ) //nolint:nakedret diff --git a/cmd/nerdctl/container_stats_windows.go b/pkg/cmd/container/stats_windows.go similarity index 86% rename from cmd/nerdctl/container_stats_windows.go rename to pkg/cmd/container/stats_windows.go index a6460b3218a..ef2c98fdfad 100644 --- a/cmd/nerdctl/container_stats_windows.go +++ b/pkg/cmd/container/stats_windows.go @@ -14,11 +14,11 @@ limitations under the License. */ -package main +package container import ( - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/statsutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/statsutil" ) func setContainerStatsAndRenderStatsEntry(previousStats *statsutil.ContainerStats, firstSet bool, anydata interface{}, pid int, interfaces []native.NetInterface) (statsutil.StatsEntry, error) { diff --git a/pkg/cmd/container/stop.go b/pkg/cmd/container/stop.go index 2a836da7bf0..3000fd611b4 100644 --- a/pkg/cmd/container/stop.go +++ b/pkg/cmd/container/stop.go @@ -20,11 +20,12 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Stop stops a list of containers specified by `reqs`. @@ -35,6 +36,9 @@ func Stop(ctx context.Context, client *containerd.Client, reqs []string, opt typ if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } + if err := cleanupNetwork(ctx, found.Container, opt.GOptions); err != nil { + return fmt.Errorf("unable to cleanup network for container: %s", found.Req) + } if err := containerutil.Stop(ctx, found.Container, opt.Timeout); err != nil { if errdefs.IsNotFound(err) { fmt.Fprintf(opt.Stderr, "No such container: %s\n", found.Req) @@ -42,7 +46,7 @@ func Stop(ctx context.Context, client *containerd.Client, reqs []string, opt typ } return err } - _, err := fmt.Fprintf(opt.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(opt.Stdout, found.Req) return err }, } diff --git a/pkg/cmd/container/top.go b/pkg/cmd/container/top.go index 5973ef11eba..35addf54887 100644 --- a/pkg/cmd/container/top.go +++ b/pkg/cmd/container/top.go @@ -28,13 +28,12 @@ package container import ( "context" "fmt" - "regexp" - "strconv" "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // ContainerTopOKBody is from https://github.com/moby/moby/blob/v20.10.6/api/types/container/container_top.go @@ -61,10 +60,7 @@ func Top(ctx context.Context, client *containerd.Client, containers []string, op if found.MatchCount > 1 { return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) } - if err := containerTop(ctx, opt.Stdout, client, found.Container.ID(), strings.Join(containers[1:], " ")); err != nil { - return err - } - return nil + return containerTop(ctx, opt.Stdout, client, found.Container.ID(), strings.Join(containers[1:], " ")) }, } @@ -76,125 +72,3 @@ func Top(ctx context.Context, client *containerd.Client, containers []string, op } return nil } - -// appendProcess2ProcList is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L49-L55 -func appendProcess2ProcList(procList *ContainerTopOKBody, fields []string) { - // Make sure number of fields equals number of header titles - // merging "overhanging" fields - process := fields[:len(procList.Titles)-1] - process = append(process, strings.Join(fields[len(procList.Titles)-1:], " ")) - procList.Processes = append(procList.Processes, process) -} - -// psPidsArg is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L119-L131 -// -// psPidsArg converts a slice of PIDs to a string consisting -// of comma-separated list of PIDs prepended by "-q". -// For example, psPidsArg([]uint32{1,2,3}) returns "-q1,2,3". -func psPidsArg(pids []uint32) string { - b := []byte{'-', 'q'} - for i, p := range pids { - b = strconv.AppendUint(b, uint64(p), 10) - if i < len(pids)-1 { - b = append(b, ',') - } - } - return string(b) -} - -// validatePSArgs is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L19-L35 -func validatePSArgs(psArgs string) error { - // NOTE: \\s does not detect unicode whitespaces. - // So we use fieldsASCII instead of strings.Fields in parsePSOutput. - // See https://github.com/docker/docker/pull/24358 - // nolint: gosimple - re := regexp.MustCompile(`\s+(\S*)=\s*(PID\S*)`) - for _, group := range re.FindAllStringSubmatch(psArgs, -1) { - if len(group) >= 3 { - k := group[1] - v := group[2] - if k != "pid" { - return fmt.Errorf("specifying \"%s=%s\" is not allowed", k, v) - } - } - } - return nil -} - -// fieldsASCII is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L37-L47 -// -// fieldsASCII is similar to strings.Fields but only allows ASCII whitespaces -func fieldsASCII(s string) []string { - fn := func(r rune) bool { - switch r { - case '\t', '\n', '\f', '\r', ' ': - return true - } - return false - } - return strings.FieldsFunc(s, fn) -} - -// hasPid is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L57-L64 -func hasPid(procs []uint32, pid int) bool { - for _, p := range procs { - if int(p) == pid { - return true - } - } - return false -} - -// parsePSOutput is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L66-L117 -func parsePSOutput(output []byte, procs []uint32) (*ContainerTopOKBody, error) { - procList := &ContainerTopOKBody{} - - lines := strings.Split(string(output), "\n") - procList.Titles = fieldsASCII(lines[0]) - - pidIndex := -1 - for i, name := range procList.Titles { - if name == "PID" { - pidIndex = i - break - } - } - if pidIndex == -1 { - return nil, fmt.Errorf("couldn't find PID field in ps output") - } - - // loop through the output and extract the PID from each line - // fixing #30580, be able to display thread line also when "m" option used - // in "docker top" client command - preContainedPidFlag := false - for _, line := range lines[1:] { - if len(line) == 0 { - continue - } - fields := fieldsASCII(line) - - var ( - p int - err error - ) - - if fields[pidIndex] == "-" { - if preContainedPidFlag { - appendProcess2ProcList(procList, fields) - } - continue - } - p, err = strconv.Atoi(fields[pidIndex]) - if err != nil { - return nil, fmt.Errorf("unexpected pid '%s': %s", fields[pidIndex], err) - } - - if hasPid(procs, p) { - preContainedPidFlag = true - appendProcess2ProcList(procList, fields) - continue - } - preContainedPidFlag = false - } - return procList, nil -} diff --git a/pkg/cmd/container/top_unix.go b/pkg/cmd/container/top_unix.go index 6281c23e072..606e6c5772d 100644 --- a/pkg/cmd/container/top_unix.go +++ b/pkg/cmd/container/top_unix.go @@ -1,4 +1,4 @@ -//go:build linux || darwin || freebsd || netbsd || openbsd +//go:build unix /* Copyright The containerd Authors. @@ -34,10 +34,12 @@ import ( "fmt" "io" "os/exec" + "regexp" + "strconv" "strings" "text/tabwriter" - "github.com/containerd/containerd" + containerd "github.com/containerd/containerd/v2/client" ) // containerTop was inspired from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L133-L189 @@ -120,3 +122,125 @@ func containerTop(ctx context.Context, stdio io.Writer, client *containerd.Clien return w.Flush() } + +// appendProcess2ProcList is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L49-L55 +func appendProcess2ProcList(procList *ContainerTopOKBody, fields []string) { + // Make sure number of fields equals number of header titles + // merging "overhanging" fields + process := fields[:len(procList.Titles)-1] + process = append(process, strings.Join(fields[len(procList.Titles)-1:], " ")) + procList.Processes = append(procList.Processes, process) +} + +// psPidsArg is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L119-L131 +// +// psPidsArg converts a slice of PIDs to a string consisting +// of comma-separated list of PIDs prepended by "-q". +// For example, psPidsArg([]uint32{1,2,3}) returns "-q1,2,3". +func psPidsArg(pids []uint32) string { + b := []byte{'-', 'q'} + for i, p := range pids { + b = strconv.AppendUint(b, uint64(p), 10) + if i < len(pids)-1 { + b = append(b, ',') + } + } + return string(b) +} + +// validatePSArgs is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L19-L35 +func validatePSArgs(psArgs string) error { + // NOTE: \\s does not detect unicode whitespaces. + // So we use fieldsASCII instead of strings.Fields in parsePSOutput. + // See https://github.com/docker/docker/pull/24358 + // nolint: gosimple + re := regexp.MustCompile(`\s+(\S*)=\s*(PID\S*)`) + for _, group := range re.FindAllStringSubmatch(psArgs, -1) { + if len(group) >= 3 { + k := group[1] + v := group[2] + if k != "pid" { + return fmt.Errorf("specifying \"%s=%s\" is not allowed", k, v) + } + } + } + return nil +} + +// fieldsASCII is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L37-L47 +// +// fieldsASCII is similar to strings.Fields but only allows ASCII whitespaces +func fieldsASCII(s string) []string { + fn := func(r rune) bool { + switch r { + case '\t', '\n', '\f', '\r', ' ': + return true + } + return false + } + return strings.FieldsFunc(s, fn) +} + +// hasPid is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L57-L64 +func hasPid(procs []uint32, pid int) bool { + for _, p := range procs { + if int(p) == pid { + return true + } + } + return false +} + +// parsePSOutput is from https://github.com/moby/moby/blob/v20.10.6/daemon/top_unix.go#L66-L117 +func parsePSOutput(output []byte, procs []uint32) (*ContainerTopOKBody, error) { + procList := &ContainerTopOKBody{} + + lines := strings.Split(string(output), "\n") + procList.Titles = fieldsASCII(lines[0]) + + pidIndex := -1 + for i, name := range procList.Titles { + if name == "PID" { + pidIndex = i + break + } + } + if pidIndex == -1 { + return nil, fmt.Errorf("couldn't find PID field in ps output") + } + + // loop through the output and extract the PID from each line + // fixing #30580, be able to display thread line also when "m" option used + // in "docker top" client command + preContainedPidFlag := false + for _, line := range lines[1:] { + if len(line) == 0 { + continue + } + fields := fieldsASCII(line) + + var ( + p int + err error + ) + + if fields[pidIndex] == "-" { + if preContainedPidFlag { + appendProcess2ProcList(procList, fields) + } + continue + } + p, err = strconv.Atoi(fields[pidIndex]) + if err != nil { + return nil, fmt.Errorf("unexpected pid '%s': %s", fields[pidIndex], err) + } + + if hasPid(procs, p) { + preContainedPidFlag = true + appendProcess2ProcList(procList, fields) + continue + } + preContainedPidFlag = false + } + return procList, nil +} diff --git a/pkg/cmd/container/top_windows.go b/pkg/cmd/container/top_windows.go index d5f68a35db9..fcdb165b9b3 100644 --- a/pkg/cmd/container/top_windows.go +++ b/pkg/cmd/container/top_windows.go @@ -26,9 +26,10 @@ import ( "time" "github.com/Microsoft/hcsshim/cmd/containerd-shim-runhcs-v1/options" - "github.com/containerd/containerd" - "github.com/containerd/typeurl/v2" "github.com/docker/go-units" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/typeurl/v2" ) // containerTop was inspired from https://github.com/moby/moby/blob/master/daemon/top_windows.go diff --git a/pkg/cmd/container/unpause.go b/pkg/cmd/container/unpause.go index 7f57dd59038..cc6f8a5781d 100644 --- a/pkg/cmd/container/unpause.go +++ b/pkg/cmd/container/unpause.go @@ -20,10 +20,11 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Unpause unpauses all containers specified by `reqs`. @@ -38,7 +39,7 @@ func Unpause(ctx context.Context, client *containerd.Client, reqs []string, opti return err } - _, err := fmt.Fprintf(options.Stdout, "%s\n", found.Req) + _, err := fmt.Fprintln(options.Stdout, found.Req) return err }, } diff --git a/pkg/cmd/container/wait.go b/pkg/cmd/container/wait.go index 4407e825b27..1dbc81d6e44 100644 --- a/pkg/cmd/container/wait.go +++ b/pkg/cmd/container/wait.go @@ -18,13 +18,14 @@ package container import ( "context" + "errors" "fmt" "io" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/hashicorp/go-multierror" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" ) // Wait blocks until all the containers specified by reqs have stopped, then print their exit codes. @@ -46,14 +47,14 @@ func Wait(ctx context.Context, client *containerd.Client, reqs []string, options return err } - var allErr error + var errs []error w := options.Stdout for _, container := range containers { if waitErr := waitContainer(ctx, w, container); waitErr != nil { - allErr = multierror.Append(allErr, waitErr) + errs = append(errs, waitErr) } } - return allErr + return errors.Join(errs...) } func waitContainer(ctx context.Context, w io.Writer, container containerd.Container) error { diff --git a/pkg/cmd/image/convert.go b/pkg/cmd/image/convert.go index 75d8d98a958..71a2cca4c54 100644 --- a/pkg/cmd/image/convert.go +++ b/pkg/cmd/image/convert.go @@ -25,26 +25,28 @@ import ( "os" "strings" + "github.com/klauspost/compress/zstd" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + overlaybdconvert "github.com/containerd/accelerated-container-image/pkg/convertor" - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/images/converter" - "github.com/containerd/containerd/images/converter/uncompress" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - converterutil "github.com/containerd/nerdctl/pkg/imgutil/converter" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" + 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/core/images/converter" + "github.com/containerd/containerd/v2/core/images/converter/uncompress" + "github.com/containerd/log" nydusconvert "github.com/containerd/nydus-snapshotter/pkg/converter" "github.com/containerd/stargz-snapshotter/estargz" estargzconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz" estargzexternaltocconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz/externaltoc" zstdchunkedconvert "github.com/containerd/stargz-snapshotter/nativeconverter/zstdchunked" "github.com/containerd/stargz-snapshotter/recorder" - "github.com/klauspost/compress/zstd" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + + "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" ) func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRawRef string, options types.ImageConvertOptions) error { @@ -55,17 +57,17 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa return errors.New("src and target image need to be specified") } - srcNamed, err := referenceutil.ParseAny(srcRawRef) + parsedReference, err := referenceutil.Parse(srcRawRef) if err != nil { return err } - srcRef := srcNamed.String() + srcRef := parsedReference.String() - targetNamed, err := referenceutil.ParseDockerRef(targetRawRef) + parsedReference, err = referenceutil.Parse(targetRawRef) if err != nil { return err } - targetRef := targetNamed.String() + targetRef := parsedReference.String() platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platforms) if err != nil { @@ -73,16 +75,26 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa } convertOpts = append(convertOpts, converter.WithPlatform(platMC)) + // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 + err = EnsureAllContent(ctx, client, srcRef, options.GOptions) + if err != nil { + return err + } + estargz := options.Estargz + zstd := options.Zstd zstdchunked := options.ZstdChunked overlaybd := options.Overlaybd nydus := options.Nydus var finalize func(ctx context.Context, cs content.Store, ref string, desc *ocispec.Descriptor) (*images.Image, error) - if estargz || zstdchunked || overlaybd || nydus { + if estargz || zstd || zstdchunked || overlaybd || nydus { convertCount := 0 if estargz { convertCount++ } + if zstd { + convertCount++ + } if zstdchunked { convertCount++ } @@ -106,6 +118,12 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa return err } convertType = "estargz" + case zstd: + convertFunc, err = getZstdConverter(options) + if err != nil { + return err + } + convertType = "zstd" case zstdchunked: convertFunc, err = getZstdchunkedConverter(options) if err != nil { @@ -153,9 +171,9 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa } if !options.Oci { if nydus || overlaybd { - logrus.Warnf("option --%s should be used in conjunction with --oci, forcibly enabling on oci mediatype for %s conversion", convertType, convertType) + log.G(ctx).Warnf("option --%s should be used in conjunction with --oci, forcibly enabling on oci mediatype for %s conversion", convertType, convertType) } else { - logrus.Warnf("option --%s should be used in conjunction with --oci", convertType) + log.G(ctx).Warnf("option --%s should be used in conjunction with --oci", convertType) } } if options.Uncompress { @@ -244,7 +262,7 @@ func getESGZConvertOpts(options types.ImageConvertOptions) ([]estargz.Option, er return nil, fmt.Errorf("estargz-record-in requires experimental mode to be enabled") } - logrus.Warn("--estargz-record-in flag is experimental and subject to change") + log.L.Warn("--estargz-record-in flag is experimental and subject to change") paths, err := readPathsFromRecordFile(options.EstargzRecordIn) if err != nil { return nil, err @@ -256,6 +274,10 @@ func getESGZConvertOpts(options types.ImageConvertOptions) ([]estargz.Option, er return esgzOpts, nil } +func getZstdConverter(options types.ImageConvertOptions) (converter.ConvertFunc, error) { + return converterutil.ZstdLayerConvertFunc(options) +} + func getZstdchunkedConverter(options types.ImageConvertOptions) (converter.ConvertFunc, error) { esgzOpts := []estargz.Option{ @@ -267,7 +289,7 @@ func getZstdchunkedConverter(options types.ImageConvertOptions) (converter.Conve return nil, fmt.Errorf("zstdchunked-record-in requires experimental mode to be enabled") } - logrus.Warn("--zstdchunked-record-in flag is experimental and subject to change") + log.L.Warn("--zstdchunked-record-in flag is experimental and subject to change") paths, err := readPathsFromRecordFile(options.ZstdChunkedRecordIn) if err != nil { return nil, err @@ -342,14 +364,14 @@ func printConvertedImage(stdout io.Writer, options types.ImageConvertOptions, im for i, e := range img.ExtraImages { elems := strings.SplitN(e, "@", 2) if len(elems) < 2 { - logrus.Errorf("extra reference %q doesn't contain digest", e) + log.L.Errorf("extra reference %q doesn't contain digest", e) } else { - logrus.Infof("Extra image(%d) %s", i, elems[0]) + log.L.Infof("Extra image(%d) %s", i, elems[0]) } } elems := strings.SplitN(img.Image, "@", 2) if len(elems) < 2 { - logrus.Errorf("reference %q doesn't contain digest", img.Image) + log.L.Errorf("reference %q doesn't contain digest", img.Image) } else { fmt.Fprintln(stdout, elems[1]) } diff --git a/pkg/cmd/image/crypt.go b/pkg/cmd/image/crypt.go index 9aa08318e1c..981d39dfd51 100644 --- a/pkg/cmd/image/crypt.go +++ b/pkg/cmd/image/crypt.go @@ -21,15 +21,17 @@ import ( "errors" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images/converter" - "github.com/containerd/imgcrypt/images/encryption" - "github.com/containerd/imgcrypt/images/encryption/parsehelpers" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" 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/images/converter" + "github.com/containerd/imgcrypt/v2/images/encryption" + "github.com/containerd/imgcrypt/v2/images/encryption/parsehelpers" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) func Crypt(ctx context.Context, client *containerd.Client, srcRawRef, targetRawRef string, encrypt bool, options types.ImageCryptOptions) error { @@ -38,17 +40,17 @@ func Crypt(ctx context.Context, client *containerd.Client, srcRawRef, targetRawR return errors.New("src and target image need to be specified") } - srcNamed, err := referenceutil.ParseAny(srcRawRef) + parsedRerefence, err := referenceutil.Parse(srcRawRef) if err != nil { return err } - srcRef := srcNamed.String() + srcRef := parsedRerefence.String() - targetNamed, err := referenceutil.ParseDockerRef(targetRawRef) + parsedRerefence, err = referenceutil.Parse(targetRawRef) if err != nil { return err } - targetRef := targetNamed.String() + targetRef := parsedRerefence.String() platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platforms) if err != nil { diff --git a/pkg/cmd/image/ensure.go b/pkg/cmd/image/ensure.go new file mode 100644 index 00000000000..fe5f6ca88c5 --- /dev/null +++ b/pkg/cmd/image/ensure.go @@ -0,0 +1,120 @@ +/* + 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 ( + "context" + "errors" + "net/http" + "os" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + + "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/dockerconfigresolver" + "github.com/containerd/nerdctl/v2/pkg/imgutil/fetch" + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" +) + +func EnsureAllContent(ctx context.Context, client *containerd.Client, srcName string, options types.GlobalCommandOptions) error { + // Get the image from the srcName + imageService := client.ImageService() + img, err := imageService.Get(ctx, srcName) + if err != nil { + return err + } + + provider := containerdutil.NewProvider(client) + snapshotter := containerdutil.SnapshotService(client, options.Snapshotter) + // Read the image + imagesList, _ := read(ctx, provider, snapshotter, img.Target) + // Iterate through the list + for _, i := range imagesList { + err = ensureOne(ctx, client, srcName, img.Target, i.platform, options) + if err != nil { + return err + } + } + + return nil +} + +func ensureOne(ctx context.Context, client *containerd.Client, rawRef string, target ocispec.Descriptor, platform ocispec.Platform, options types.GlobalCommandOptions) error { + parsedReference, err := referenceutil.Parse(rawRef) + if err != nil { + return err + } + pltf := []ocispec.Platform{platform} + platformComparer := platformutil.NewMatchComparerFromOCISpecPlatformSlice(pltf) + + _, _, _, missing, err := images.Check(ctx, client.ContentStore(), target, platformComparer) + if err != nil { + return err + } + + if len(missing) > 0 { + // Get a resolver + var dOpts []dockerconfigresolver.Opt + if options.InsecureRegistry { + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", parsedReference.Domain) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.HostsDir)) + resolver, err := dockerconfigresolver.New(ctx, parsedReference.Domain, dOpts...) + if err != nil { + return err + } + config := &fetch.Config{ + Resolver: resolver, + RemoteOpts: []containerd.RemoteOpt{}, + Platforms: pltf, + ProgressOutput: os.Stderr, + } + + err = fetch.Fetch(ctx, client, rawRef, config) + + if 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.InsecureRegistry { + log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", parsedReference.Domain) + dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) + resolver, err = dockerconfigresolver.New(ctx, parsedReference.Domain, dOpts...) + if err != nil { + return err + } + config.Resolver = resolver + return fetch.Fetch(ctx, client, rawRef, config) + } + log.G(ctx).WithError(err).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 err + } + + return nil +} diff --git a/pkg/cmd/image/inspect.go b/pkg/cmd/image/inspect.go index 16cd258e02a..ec96ddb7cba 100644 --- a/pkg/cmd/image/inspect.go +++ b/pkg/cmd/image/inspect.go @@ -18,59 +18,190 @@ package image import ( "context" + "errors" "fmt" + "regexp" + "strings" "time" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/imageinspector" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/imageinspector" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) +func inspectIdentifier(ctx context.Context, client *containerd.Client, identifier string) ([]images.Image, string, string, error) { + // Figure out what we have here - digest, tag, name + parsedReference, err := referenceutil.Parse(identifier) + if err != nil { + return nil, "", "", err + } + digest := "" + if parsedReference.Digest != "" { + digest = parsedReference.Digest.String() + } + name := parsedReference.Name() + tag := parsedReference.Tag + + // Initialize filters + var filters []string + // This will hold the final image list, if any + var imageList []images.Image + + // No digest in the request? Then assume it is a name + if digest == "" { + filters = []string{fmt.Sprintf("name==%s:%s", name, tag)} + // Query it + imageList, err = client.ImageService().List(ctx, filters...) + if err != nil { + return nil, "", "", fmt.Errorf("containerd image service failed: %w", err) + } + // Nothing? Then it could be a short id (aka truncated digest) - we are going to use this + if len(imageList) == 0 { + digest = fmt.Sprintf("sha256:%s.*", regexp.QuoteMeta(strings.TrimPrefix(identifier, "sha256:"))) + name = "" + tag = "" + } else { + // Otherwise, we found one by name. Get the digest from it. + digest = imageList[0].Target.Digest.String() + } + } + + // At this point, we DO have a digest (or short id), so, that is what we are retrieving + filters = []string{fmt.Sprintf("target.digest~=^%s$", digest)} + imageList, err = client.ImageService().List(ctx, filters...) + if err != nil { + return nil, "", "", fmt.Errorf("containerd image service failed: %w", err) + } + + // TODO: docker does allow retrieving images by Id, so implement as a last ditch effort (probably look-up the store) + + // Return the list we found, along with normalized name and tag + return imageList, name, tag, nil +} + // Inspect prints detailed information of each image in `images`. -func Inspect(ctx context.Context, client *containerd.Client, images []string, options types.ImageInspectOptions) error { - f := &imageInspector{ - mode: options.Mode, +func Inspect(ctx context.Context, client *containerd.Client, identifiers []string, options types.ImageInspectOptions) error { + // Verify we have a valid mode + // TODO: move this out of here, to Cobra command line arg validation + if options.Mode != "native" && options.Mode != "dockercompat" { + return fmt.Errorf("unknown mode %q", options.Mode) } - walker := &imagewalker.ImageWalker{ - Client: client, - OnFound: func(ctx context.Context, found imagewalker.Found) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() + // Set a timeout + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // Will hold the final answers + var errs []error + var entries []interface{} - n, err := imageinspector.Inspect(ctx, client, found.Image, options.GOptions.Snapshotter) + snapshotter := containerdutil.SnapshotService(client, options.GOptions.Snapshotter) + // We have to query per provided identifier, as we need to post-process results for the case name + digest + for _, identifier := range identifiers { + candidateImageList, requestedName, requestedTag, err := inspectIdentifier(ctx, client, identifier) + if err != nil { + errs = append(errs, fmt.Errorf("%w: %s", err, identifier)) + continue + } + + var validatedImage *dockercompat.Image + var repoTags []string + var repoDigests []string + + // Go through the candidates + for _, candidateImage := range candidateImageList { + // Inspect the image + candidateNativeImage, err := imageinspector.Inspect(ctx, client, candidateImage, snapshotter) if err != nil { - return err + log.G(ctx).WithError(err).WithField("name", candidateImage.Name).Error("failure inspecting image") + continue } - switch f.mode { - case "native": - f.entries = append(f.entries, n) - case "dockercompat": - d, err := dockercompat.ImageFromNative(n) + + // If native, we just add everything in there and that's it + if options.Mode == "native" { + entries = append(entries, candidateNativeImage) + continue + } + + // If dockercompat: does the candidate have a name? Get it if so + parsedReference, err := referenceutil.Parse(candidateNativeImage.Image.Name) + if err != nil { + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("the found image has an unparsable name") + continue + } + + // If we were ALSO asked for a specific name on top of the digest, we need to make sure we keep only the image with that name + if requestedName != "" { + // If the candidate did not have a name, then we should ignore this one and continue + if parsedReference.Name() == "" { + continue + } + + // Otherwise, the candidate has a name. If it is the one we want, store it and continue, otherwise, fall through + candidateTag := parsedReference.Tag + // If the name had a digest, an empty tag is not normalized to latest, so, account for that here + if requestedTag == "" { + requestedTag = "latest" + } + if parsedReference.Name() == requestedName && candidateTag == requestedTag { + validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage) + if err != nil { + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image") + } + continue + } + } else if validatedImage == nil { + // Alternatively, we got a request by digest only, so, if we do not know about it already, store it and continue + validatedImage, err = dockercompat.ImageFromNative(candidateNativeImage) if err != nil { - return err + log.G(ctx).WithError(err).WithField("name", candidateNativeImage.Image.Name).Error("could not get a docker compat version of the native image") } - f.entries = append(f.entries, d) - default: - return fmt.Errorf("unknown mode %q", f.mode) + continue } - return nil - }, + + // Fallthrough cases: + // - we got a request by digest, but we already had the image stored + // - we got a request by name, and the name of the candidate did not match the requested name + // Now, check if the candidate has a name - if it does, populate repoTags and repoDigests + if parsedReference.Name() != "" { + tag := parsedReference.Tag + if tag == "" { + tag = "latest" + } + repoTags = append(repoTags, fmt.Sprintf("%s:%s", parsedReference.FamiliarName(), tag)) + repoDigests = append(repoDigests, fmt.Sprintf("%s@%s", parsedReference.FamiliarName(), candidateImage.Target.Digest.String())) + } + } + + // Done iterating through candidates. Did we find anything that matches? + if validatedImage != nil { + // Then slap in the repoTags and repoDigests we found from the other candidates + validatedImage.RepoTags = append(validatedImage.RepoTags, repoTags...) + validatedImage.RepoDigests = append(validatedImage.RepoDigests, repoDigests...) + // Store our image + // foundImages[validatedDigest] = validatedImage + entries = append(entries, validatedImage) + } else { + errs = append(errs, fmt.Errorf("no such image: %s", identifier)) + } } - err := walker.WalkAll(ctx, images, true) - if len(f.entries) > 0 { - if formatErr := formatter.FormatSlice(options.Format, options.Stdout, f.entries); formatErr != nil { - logrus.Error(formatErr) + // Display + if len(entries) > 0 { + if formatErr := formatter.FormatSlice(options.Format, options.Stdout, entries); formatErr != nil { + log.G(ctx).Error(formatErr) } } - return err -} -type imageInspector struct { - mode string - entries []interface{} + if len(errs) > 0 { + return fmt.Errorf("%d errors:\n%w", len(errs), errors.Join(errs...)) + } + + return nil } diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index 247757b2374..c7440b459fb 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -19,29 +19,37 @@ package image import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" + "sort" "strings" "text/tabwriter" "text/template" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/containerd/platforms" - "github.com/containerd/containerd/snapshots" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/imgutil" - v1 "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + "github.com/docker/go-units" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/identity" + 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/images" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/log" + "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/formatter" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) // ListCommandHandler `List` and print images matching filters in `options`. -func ListCommandHandler(ctx context.Context, client *containerd.Client, options types.ImageListOptions) error { +func ListCommandHandler(ctx context.Context, client *containerd.Client, options *types.ImageListOptions) error { imageList, err := List(ctx, client, options.Filters, options.NameAndRefFilter) if err != nil { return err @@ -74,37 +82,34 @@ func List(ctx context.Context, client *containerd.Client, filters, nameAndRefFil return nil, err } - if f.Dangling != nil { - imageList = imgutil.FilterDangling(imageList, *f.Dangling) + filters := []imgutil.Filter{} + if f.Dangling != nil && *f.Dangling { + filters = append(filters, imgutil.FilterDanglingImages()) + } else if f.Dangling != nil { + filters = append(filters, imgutil.FilterTaggedImages()) } - imageList, err = imgutil.FilterByLabel(ctx, client, imageList, f.Labels) - if err != nil { - return nil, err + if len(f.Labels) > 0 { + filters = append(filters, imgutil.FilterByLabel(ctx, client, f.Labels)) } - imageList, err = imgutil.FilterByReference(imageList, f.Reference) - if err != nil { - return nil, err + if len(f.Reference) > 0 { + filters = append(filters, imgutil.FilterByReference(f.Reference)) } - var beforeImages []images.Image - if len(f.Before) > 0 { - beforeImages, err = imageStore.List(ctx, f.Before...) - if err != nil { - return nil, err - } - } - var sinceImages []images.Image - if len(f.Since) > 0 { - sinceImages, err = imageStore.List(ctx, f.Since...) - if err != nil { - return nil, err - } + if len(f.Before) > 0 || len(f.Since) > 0 { + filters = append(filters, imgutil.FilterByCreatedAt(ctx, client, f.Before, f.Since)) } - imageList = imgutil.FilterImages(imageList, beforeImages, sinceImages) + imageList, err = imgutil.ApplyFilters(imageList, filters...) + if err != nil { + return []images.Image{}, err + } } + + sort.Slice(imageList, func(i, j int) bool { + return imageList[i].CreatedAt.After(imageList[j].CreatedAt) + }) return imageList, nil } @@ -123,8 +128,48 @@ type imagePrintable struct { Platform string // nerdctl extension } -func printImages(ctx context.Context, client *containerd.Client, imageList []images.Image, options types.ImageListOptions) error { +func printImages(ctx context.Context, client *containerd.Client, imageList []images.Image, options *types.ImageListOptions) error { w := options.Stdout + var finalImageList []images.Image + /* + the same imageId under k8s.io is showing multiple results: repo:tag, repo:digest, configID. + We expect to display only repo:tag, consistent with other namespaces and CRI + e.g. + nerdctl -n k8s.io images + REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE + centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + centos be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + expect: + nerdctl --kube-hide-dupe -n k8s.io images + REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE + centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + */ + if options.GOptions.KubeHideDupe && options.GOptions.Namespace == "k8s.io" { + imageDigest := make(map[digest.Digest]bool) + var imageNoTag []images.Image + for _, img := range imageList { + parsed, err := referenceutil.Parse(img.Name) + if err != nil { + continue + } + if parsed.Tag != "" { + finalImageList = append(finalImageList, img) + imageDigest[img.Target.Digest] = true + continue + } + imageNoTag = append(imageNoTag, img) + } + //Ensure that dangling images without a repo:tag are displayed correctly. + for _, ima := range imageNoTag { + if !imageDigest[ima.Target.Digest] { + finalImageList = append(finalImageList, ima) + imageDigest[ima.Target.Digest] = true + } + } + } else { + finalImageList = imageList + } digestsFlag := options.Digests if options.Format == "wide" { digestsFlag = true @@ -160,20 +205,20 @@ func printImages(ctx context.Context, client *containerd.Client, imageList []ima } printer := &imagePrinter{ - w: w, - quiet: options.Quiet, - noTrunc: options.NoTrunc, - digestsFlag: digestsFlag, - namesFlag: options.Names, - tmpl: tmpl, - client: client, - contentStore: client.ContentStore(), - snapshotter: client.SnapshotService(options.GOptions.Snapshotter), + w: w, + quiet: options.Quiet, + noTrunc: options.NoTrunc, + digestsFlag: digestsFlag, + namesFlag: options.Names, + tmpl: tmpl, + client: client, + provider: containerdutil.NewProvider(client), + snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), } - for _, img := range imageList { + for _, img := range finalImageList { if err := printer.printImage(ctx, img); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } } if f, ok := w.(formatter.Flusher); ok { @@ -187,36 +232,127 @@ type imagePrinter struct { quiet, noTrunc, digestsFlag, namesFlag bool tmpl *template.Template client *containerd.Client - contentStore content.Store + provider content.Provider snapshotter snapshots.Snapshotter } -func (x *imagePrinter) printImage(ctx context.Context, img images.Image) error { - ociPlatforms, err := images.Platforms(ctx, x.contentStore, img.Target) +type image struct { + blobSize int64 + size int64 + platform platforms.Platform + config *ocispec.Descriptor +} + +func readManifest(ctx context.Context, provider content.Provider, snapshotter snapshots.Snapshotter, desc ocispec.Descriptor) (*image, error) { + // Read the manifest blob from the descriptor + manifestData, err := containerdutil.ReadBlob(ctx, provider, desc) if err != nil { - logrus.WithError(err).Warnf("failed to get the platform list of image %q", img.Name) - return x.printImageSinglePlatform(ctx, img, platforms.DefaultSpec()) + return nil, err + } + + // Unmarshal as Manifest + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, err } - for _, ociPlatform := range ociPlatforms { - if err := x.printImageSinglePlatform(ctx, img, ociPlatform); err != nil { - logrus.WithError(err).Warnf("failed to get platform %q of image %q", platforms.Format(ociPlatform), img.Name) + + // Now, read the config + configData, err := containerdutil.ReadBlob(ctx, provider, manifest.Config) + if err != nil { + return nil, err + } + + // Unmarshal as Image + var config ocispec.Image + if err := json.Unmarshal(configData, &config); err != nil { + log.G(ctx).Error("Error unmarshaling config") + return nil, err + } + + // If we are here, the image exists and is valid, so, do our size lookups + + // Aggregate the descriptor size, and blob size from the config and layers + blobSize := desc.Size + manifest.Config.Size + for _, layerDescriptor := range manifest.Layers { + blobSize += layerDescriptor.Size + } + + // Get the platform + plt := platforms.Normalize(ocispec.Platform{OS: config.OS, Architecture: config.Architecture, Variant: config.Variant}) + + // Get the filesystem size for all layers + chainID := identity.ChainID(config.RootFS.DiffIDs).String() + size := int64(0) + if _, actualSize, err := imgutil.ResourceUsage(ctx, snapshotter, chainID); err == nil { + size = actualSize.Size + } + + return &image{ + blobSize: blobSize, + size: size, + platform: plt, + config: &manifest.Config, + }, nil +} + +func readIndex(ctx context.Context, provider content.Provider, snapshotter snapshots.Snapshotter, desc ocispec.Descriptor) (map[string]*image, error) { + descs := map[string]*image{} + + // Read the index + indexData, err := containerdutil.ReadBlob(ctx, provider, desc) + if err != nil { + return nil, err + } + + // Unmarshal as Index + var index ocispec.Index + if err := json.Unmarshal(indexData, &index); err != nil { + return nil, err + } + + // Iterate over manifest descriptors and read them all + for _, manifestDescriptor := range index.Manifests { + manifest, err := readManifest(ctx, provider, snapshotter, manifestDescriptor) + if err != nil { + continue } + descs[platforms.FormatAll(manifest.platform)] = manifest } - return nil + return descs, err } -func (x *imagePrinter) printImageSinglePlatform(ctx context.Context, img images.Image, ociPlatform v1.Platform) error { - platMC := platforms.OnlyStrict(ociPlatform) - if avail, _, _, _, availErr := images.Check(ctx, x.contentStore, img.Target, platMC); !avail { - logrus.WithError(availErr).Debugf("skipping printing image %q for platform %q", img.Name, platforms.Format(ociPlatform)) - return nil +func read(ctx context.Context, provider content.Provider, snapshotter snapshots.Snapshotter, desc ocispec.Descriptor) (map[string]*image, error) { + if images.IsManifestType(desc.MediaType) { + manifest, err := readManifest(ctx, provider, snapshotter, desc) + if err != nil { + return nil, err + } + descs := map[string]*image{} + descs[platforms.FormatAll(manifest.platform)] = manifest + return descs, nil + } + if images.IsIndexType(desc.MediaType) { + return readIndex(ctx, provider, snapshotter, desc) } + return nil, fmt.Errorf("unknown media type: %s", desc.MediaType) +} - image := containerd.NewImageWithPlatform(x.client, img, platMC) - desc, err := image.Config(ctx) +func (x *imagePrinter) printImage(ctx context.Context, img images.Image) error { + candidateImages, err := read(ctx, x.provider, x.snapshotter, img.Target) if err != nil { - logrus.WithError(err).Warnf("failed to get config of image %q for platform %q", img.Name, platforms.Format(ociPlatform)) + return err } + + for platform, desc := range candidateImages { + if err := x.printImageSinglePlatform(*desc.config, img, desc.blobSize, desc.size, desc.platform); err != nil { + log.G(ctx).WithError(err).Debugf("failed to get platform %q of image %q", platform, img.Name) + } + } + + return nil +} + +func (x *imagePrinter) printImageSinglePlatform(desc ocispec.Descriptor, img images.Image, blobSize int64, size int64, plt platforms.Platform) error { var ( repository string tag string @@ -226,17 +362,6 @@ func (x *imagePrinter) printImageSinglePlatform(ctx context.Context, img images. repository, tag = imgutil.ParseRepoTag(img.Name) } - blobSize, err := image.Size(ctx) - if err != nil { - logrus.WithError(err).Warnf("failed to get blob size of image %q for platform %q", img.Name, platforms.Format(ociPlatform)) - } - - size, err := imgutil.UnpackedImageSize(ctx, x.snapshotter, image) - if err != nil { - // Warnf is too verbose: https://github.com/containerd/nerdctl/issues/2058 - logrus.WithError(err).Debugf("failed to get unpacked size of image %q for platform %q", img.Name, platforms.Format(ociPlatform)) - } - p := imagePrintable{ CreatedAt: img.CreatedAt.Round(time.Second).Local().String(), // format like "2021-08-07 02:19:45 +0900 JST" CreatedSince: formatter.TimeSinceInHuman(img.CreatedAt), @@ -245,9 +370,9 @@ func (x *imagePrinter) printImageSinglePlatform(ctx context.Context, img images. Repository: repository, Tag: tag, Name: img.Name, - Size: progress.Bytes(size).String(), - BlobSize: progress.Bytes(blobSize).String(), - Platform: platforms.Format(ociPlatform), + Size: units.HumanSize(float64(size)), + BlobSize: units.HumanSize(float64(blobSize)), + Platform: platforms.FormatAll(plt), } if p.Repository == "" { p.Repository = "" @@ -264,11 +389,11 @@ func (x *imagePrinter) printImageSinglePlatform(ctx context.Context, img images. if err := x.tmpl.Execute(&b, p); err != nil { return err } - if _, err = fmt.Fprintf(x.w, b.String()+"\n"); err != nil { + if _, err := fmt.Fprintln(x.w, b.String()); err != nil { return err } } else if x.quiet { - if _, err := fmt.Fprintf(x.w, "%s\n", p.ID); err != nil { + if _, err := fmt.Fprintln(x.w, p.ID); err != nil { return err } } else { diff --git a/pkg/cmd/image/load.go b/pkg/cmd/image/load.go deleted file mode 100644 index b3871279dd5..00000000000 --- a/pkg/cmd/image/load.go +++ /dev/null @@ -1,113 +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 image - -import ( - "context" - "errors" - "fmt" - "io" - "os" - - "github.com/containerd/containerd" - "github.com/containerd/containerd/archive/compression" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/images/archive" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/platformutil" -) - -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 Load(ctx context.Context, client *containerd.Client, options types.ImageLoadOptions) error { - if options.Input != "" { - f, err := os.Open(options.Input) - if err != nil { - return err - } - defer f.Close() - options.Stdin = f - } else { - // check if stdin is empty. - stdinStat, err := os.Stdin.Stat() - if err != nil { - return err - } - if stdinStat.Size() == 0 && (stdinStat.Mode()&os.ModeNamedPipe) == 0 { - return errors.New("stdin is empty and input flag is not specified") - } - } - decompressor, err := compression.DecompressStream(options.Stdin) - if err != nil { - return err - } - platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) - if err != nil { - return err - } - return loadImage(ctx, client, decompressor, platMC, false, options) -} - -func loadImage(ctx context.Context, client *containerd.Client, in io.Reader, platMC platforms.MatchComparer, quiet bool, options types.ImageLoadOptions) 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(options.GOptions.Snapshotter)), containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), containerd.WithImportPlatform(platMC)) - if err != nil { - if r.N == 0 { - // Avoid confusing "unrecognized image format" - return 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 err - } - for _, img := range imgs { - image := containerd.NewImageWithPlatform(client, img, platMC) - - // TODO: Show unpack status - if !quiet { - fmt.Fprintf(options.Stdout, "unpacking %s (%s)...\n", img.Name, img.Target.Digest) - } - err = image.Unpack(ctx, options.GOptions.Snapshotter) - if err != nil { - return err - } - if quiet { - fmt.Fprintln(options.Stdout, img.Target.Digest) - } else { - repo, tag := imgutil.ParseRepoTag(img.Name) - fmt.Fprintf(options.Stdout, "Loaded image: %s:%s\n", repo, tag) - } - } - - return nil -} diff --git a/pkg/cmd/image/prune.go b/pkg/cmd/image/prune.go index 32733153508..da29fbdb486 100644 --- a/pkg/cmd/image/prune.go +++ b/pkg/cmd/image/prune.go @@ -20,60 +20,63 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/imgutil" "github.com/opencontainers/go-digest" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" ) // Prune will remove all dangling images. If all is specified, will also remove all images not referenced by any container. func Prune(ctx context.Context, client *containerd.Client, options types.ImagePruneOptions) error { var ( - imageStore = client.ImageService() - contentStore = client.ContentStore() - containerStore = client.ContainerService() + imageStore = client.ImageService() + contentStore = client.ContentStore() ) - imageList, err := imageStore.List(ctx) - if err != nil { - return err - } - - var filteredImages []images.Image + var ( + imagesToBeRemoved []images.Image + err error + ) - if options.All { - containerList, err := containerStore.List(ctx) + filters := []imgutil.Filter{} + if len(options.Filters) > 0 { + parsedFilters, err := imgutil.ParseFilters(options.Filters) if err != nil { return err } - usedImages := make(map[string]struct{}) - for _, container := range containerList { - usedImages[container.Image] = struct{}{} + if len(parsedFilters.Labels) > 0 { + filters = append(filters, imgutil.FilterByLabel(ctx, client, parsedFilters.Labels)) } - - for _, image := range imageList { - if _, ok := usedImages[image.Name]; ok { - continue - } - - filteredImages = append(filteredImages, image) + if len(parsedFilters.Until) > 0 { + filters = append(filters, imgutil.FilterUntil(parsedFilters.Until)) } + } + + if options.All { + // Remove all unused images; not just dangling ones + imagesToBeRemoved, err = imgutil.GetUnusedImages(ctx, client, filters...) } else { - filteredImages = imgutil.FilterDangling(imageList, true) + // Remove dangling images only + imagesToBeRemoved, err = imgutil.GetDanglingImages(ctx, client, filters...) + } + if err != nil { + return err } delOpts := []images.DeleteOpt{images.SynchronousDelete()} removedImages := make(map[string][]digest.Digest) - for _, image := range filteredImages { + for _, image := range imagesToBeRemoved { digests, err := image.RootFS(ctx, contentStore, platforms.DefaultStrict()) if err != nil { - logrus.WithError(err).Warnf("failed to enumerate rootfs") + log.G(ctx).WithError(err).Warnf("failed to enumerate rootfs") } if err := imageStore.Delete(ctx, image.Name, delOpts...); err != nil { - logrus.WithError(err).Warnf("failed to delete image %s", image.Name) + log.G(ctx).WithError(err).Warnf("failed to delete image %s", image.Name) continue } removedImages[image.Name] = digests diff --git a/pkg/cmd/image/pull.go b/pkg/cmd/image/pull.go index 50ac66bb97f..1d943c9b62d 100644 --- a/pkg/cmd/image/pull.go +++ b/pkg/cmd/image/pull.go @@ -22,30 +22,18 @@ import ( "os" "path/filepath" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/ipfs" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/containerd/nerdctl/pkg/signutil" - "github.com/containerd/nerdctl/pkg/strutil" - v1 "github.com/opencontainers/image-spec/specs-go/v1" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/ipfs" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/signutil" ) // Pull pulls an image specified by `rawRef`. func Pull(ctx context.Context, client *containerd.Client, rawRef string, options types.ImagePullOptions) error { - ocispecPlatforms, err := platformutil.NewOCISpecPlatformSlice(options.AllPlatforms, options.Platform) - if err != nil { - return err - } - - unpack, err := strutil.ParseBoolOrAuto(options.Unpack) - if err != nil { - return err - } - - _, err = EnsureImage(ctx, client, rawRef, ocispecPlatforms, "always", unpack, options.Quiet, options) + _, err := EnsureImage(ctx, client, rawRef, options) if err != nil { return err } @@ -54,10 +42,15 @@ func Pull(ctx context.Context, client *containerd.Client, rawRef string, options } // EnsureImage pulls an image either from ipfs or from registry. -func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, ocispecPlatforms []v1.Platform, pull string, unpack *bool, quiet bool, options types.ImagePullOptions) (*imgutil.EnsuredImage, error) { +func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, options types.ImagePullOptions) (*imgutil.EnsuredImage, error) { var ensured *imgutil.EnsuredImage - if scheme, ref, err := referenceutil.ParseIPFSRefWithScheme(rawRef); err == nil { + parsedReference, err := referenceutil.Parse(rawRef) + if err != nil { + return nil, err + } + + if parsedReference.Protocol != "" { if options.VerifyOptions.Provider != "none" { return nil, errors.New("--verify flag is not supported on IPFS as of now") } @@ -75,8 +68,7 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, ipfsPath = dir } - ensured, err = ipfs.EnsureImage(ctx, client, options.Stdout, options.Stderr, options.GOptions.Snapshotter, scheme, ref, - pull, ocispecPlatforms, unpack, quiet, ipfsPath) + ensured, err = ipfs.EnsureImage(ctx, client, string(parsedReference.Protocol), parsedReference.String(), ipfsPath, options) if err != nil { return nil, err } @@ -88,8 +80,7 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, return nil, err } - ensured, err = imgutil.EnsureImage(ctx, client, options.Stdout, options.Stderr, options.GOptions.Snapshotter, ref, - pull, options.GOptions.InsecureRegistry, options.GOptions.HostsDir, ocispecPlatforms, unpack, quiet) + ensured, err = imgutil.EnsureImage(ctx, client, ref, options) if err != nil { return nil, err } diff --git a/pkg/cmd/image/push.go b/pkg/cmd/image/push.go index c9029bf17d8..a940f832f99 100644 --- a/pkg/cmd/image/push.go +++ b/pkg/cmd/image/push.go @@ -18,41 +18,59 @@ package image import ( "context" + "errors" "fmt" "io" + "net/http" "os" "path/filepath" - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/images/converter" - "github.com/containerd/containerd/reference" - refdocker "github.com/containerd/containerd/reference/docker" - "github.com/containerd/containerd/remotes" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" - "github.com/containerd/nerdctl/pkg/imgutil/push" - "github.com/containerd/nerdctl/pkg/ipfs" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/containerd/nerdctl/pkg/signutil" + "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/images" + "github.com/containerd/containerd/v2/core/images/converter" + "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/pkg/reference" + "github.com/containerd/log" "github.com/containerd/stargz-snapshotter/estargz" "github.com/containerd/stargz-snapshotter/estargz/zstdchunked" estargzconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz" - "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + + "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/imgutil/push" + "github.com/containerd/nerdctl/v2/pkg/ipfs" + "github.com/containerd/nerdctl/v2/pkg/platformutil" + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/signutil" + "github.com/containerd/nerdctl/v2/pkg/snapshotterutil" ) // Push pushes an image specified by `rawRef`. func Push(ctx context.Context, client *containerd.Client, rawRef string, options types.ImagePushOptions) error { - if scheme, ref, err := referenceutil.ParseIPFSRefWithScheme(rawRef); err == nil { - if scheme != "ipfs" { - return fmt.Errorf("ipfs scheme is only supported but got %q", scheme) + parsedReference, err := referenceutil.Parse(rawRef) + if err != nil { + return err + } + + if parsedReference.Protocol != "" { + if parsedReference.Protocol != referenceutil.IPFSProtocol { + return fmt.Errorf("ipfs scheme is only supported but got %q", parsedReference.Protocol) + } + log.G(ctx).Infof("pushing image %q to IPFS", parsedReference) + + // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3489 + // XXX what if the image is a CID, or only otherwise available on ipfs? + err = EnsureAllContent(ctx, client, parsedReference.String(), options.GOptions) + if err != nil { + return err } - logrus.Infof("pushing image %q to IPFS", ref) var ipfsPath string if options.IpfsAddress != "" { @@ -71,21 +89,21 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options if options.Estargz { layerConvert = eStargzConvertFunc() } - c, err := ipfs.Push(ctx, client, ref, layerConvert, options.AllPlatforms, options.Platforms, options.IpfsEnsureImage, ipfsPath) + c, err := ipfs.Push(ctx, client, parsedReference.String(), layerConvert, options.AllPlatforms, options.Platforms, options.IpfsEnsureImage, ipfsPath) if err != nil { - logrus.WithError(err).Warnf("ipfs push failed") + log.G(ctx).WithError(err).Warnf("ipfs push failed") return err } fmt.Fprintln(options.Stdout, c) return nil } - named, err := refdocker.ParseDockerRef(rawRef) + parsedReference, err = referenceutil.Parse(rawRef) if err != nil { return err } - ref := named.String() - refDomain := refdocker.Domain(named) + ref := parsedReference.String() + refDomain := parsedReference.Domain platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platforms) if err != nil { @@ -104,7 +122,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return fmt.Errorf("failed to create a tmp reduced-platform image %q (platform=%v): %w", pushRef, options.Platforms, err) } defer client.ImageService().Delete(ctx, platImg.Name, images.SynchronousDelete()) - logrus.Infof("pushing as a reduced-platform image (%s, %s)", platImg.Target.MediaType, platImg.Target.Digest) + log.G(ctx).Infof("pushing as a reduced-platform image (%s, %s)", platImg.Target.MediaType, platImg.Target.Digest) } if options.Estargz { @@ -114,30 +132,45 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return fmt.Errorf("failed to convert to eStargz: %v", err) } defer client.ImageService().Delete(ctx, esgzImg.Name, images.SynchronousDelete()) - logrus.Infof("pushing as an eStargz image (%s, %s)", esgzImg.Target.MediaType, esgzImg.Target.Digest) + 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, options.Stdout, pushRef, ref, platMC, options.AllowNondistributableArtifacts, options.Quiet) + return push.Push(ctx, client, r, pushTracker, options.Stdout, pushRef, ref, platMC, options.AllowNondistributableArtifacts, options.Quiet) } var dOpts []dockerconfigresolver.Opt if options.GOptions.InsecureRegistry { - logrus.Warnf("skipping verifying HTTPS certs for %q", refDomain) + 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)) - resolver, err := dockerconfigresolver.New(ctx, refDomain, dOpts...) + + 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 !errutil.IsErrHTTPResponseToHTTPSClient(err) && !errutil.IsErrConnectionRefused(err) { + if !errors.Is(err, http.ErrSchemeMismatch) && !errutil.IsErrConnectionRefused(err) { return err } if options.GOptions.InsecureRegistry { - logrus.WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) + 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 { @@ -145,8 +178,8 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options } return pushFunc(resolver) } - logrus.WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) - logrus.Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + 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 } @@ -164,6 +197,14 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options options.SignOptions); err != nil { return err } + if options.GOptions.Snapshotter == "soci" { + if err = snapshotterutil.CreateSoci(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 { + return err + } + } if options.Quiet { fmt.Fprintln(options.Stdout, ref) } @@ -174,14 +215,14 @@ func eStargzConvertFunc() converter.ConvertFunc { convertToESGZ := estargzconvert.LayerConvertFunc() return func(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) { if isReusableESGZ(ctx, cs, desc) { - logrus.Infof("reusing estargz %s without conversion", desc.Digest) + log.L.Infof("reusing estargz %s without conversion", desc.Digest) return nil, nil } newDesc, err := convertToESGZ(ctx, cs, desc) if err != nil { return nil, err } - logrus.Infof("converted %q to %s", desc.MediaType, newDesc.Digest) + log.L.Infof("converted %q to %s", desc.MediaType, newDesc.Digest) return newDesc, err } diff --git a/pkg/cmd/image/remove.go b/pkg/cmd/image/remove.go index f2aab2058e4..6b9f78fd757 100644 --- a/pkg/cmd/image/remove.go +++ b/pkg/cmd/image/remove.go @@ -22,13 +22,14 @@ import ( "fmt" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" ) // Remove removes a list of `images`. @@ -64,12 +65,32 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio walker := &imagewalker.ImageWalker{ Client: client, OnFound: func(ctx context.Context, found imagewalker.Found) error { - // if found multiple images, return error unless in force-mode and - // there is only 1 unique image. - if found.MatchCount > 1 && !(options.Force && found.UniqueImages == 1) { - return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + if found.NameMatchIndex == -1 { + // if found multiple images, return error unless in force-mode and + // there is only 1 unique image. + if found.MatchCount > 1 && !(options.Force && found.UniqueImages == 1) { + return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + } else if found.NameMatchIndex != found.MatchIndex { + // when there is an image with a name matching the argument but the argument is a digest short id, + // the deletion process is not performed. + return nil } + if cid, ok := runningImages[found.Image.Name]; ok { + if options.Force { + if err = is.Delete(ctx, found.Image.Name); err != nil { + return err + } + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Name) + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Target.Digest.String()) + + found.Image.Name = ":" + if _, err = is.Create(ctx, found.Image); err != nil { + return err + } + return nil + } return fmt.Errorf("conflict: unable to delete %s (cannot be forced) - image is being used by running container %s", found.Req, cid) } if cid, ok := usedImages[found.Image.Name]; ok && !options.Force { @@ -78,7 +99,7 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio // digests is used only for emulating human-readable output of `docker rmi` digests, err := found.Image.RootFS(ctx, cs, platforms.DefaultStrict()) if err != nil { - logrus.WithError(err).Warning("failed to enumerate rootfs") + log.G(ctx).WithError(err).Warning("failed to enumerate rootfs") } if err := is.Delete(ctx, found.Image.Name, delOpts...); err != nil { @@ -90,12 +111,64 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio } return nil }, + OnFoundCriRm: func(ctx context.Context, found imagewalker.Found) (bool, error) { + if found.NameMatchIndex == -1 { + // if found multiple images, return error unless in force-mode and + // there is only 1 unique image. + if found.MatchCount > 1 && !(options.Force && found.UniqueImages == 1) { + return false, fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) + } + } else if found.NameMatchIndex != found.MatchIndex { + // when there is an image with a name matching the argument but the argument is a digest short id, + // the deletion process is not performed. + return false, nil + } + + if cid, ok := runningImages[found.Image.Name]; ok { + if options.Force { + if err = is.Delete(ctx, found.Image.Name); err != nil { + return false, err + } + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Name) + fmt.Fprintf(options.Stdout, "Untagged: %s\n", found.Image.Target.Digest.String()) + + found.Image.Name = ":" + if _, err = is.Create(ctx, found.Image); err != nil { + return false, err + } + return false, nil + } + return false, fmt.Errorf("conflict: unable to delete %s (cannot be forced) - image is being used by running container %s", found.Req, cid) + } + if cid, ok := usedImages[found.Image.Name]; ok && !options.Force { + return false, fmt.Errorf("conflict: unable to delete %s (must be forced) - image is being used by stopped container %s", found.Req, cid) + } + // digests is used only for emulating human-readable output of `docker rmi` + digests, err := found.Image.RootFS(ctx, cs, platforms.DefaultStrict()) + if err != nil { + log.G(ctx).WithError(err).Warning("failed to enumerate rootfs") + } + + if err := is.Delete(ctx, found.Image.Name, delOpts...); err != nil { + return false, err + } + fmt.Fprintf(options.Stdout, "Untagged: %s@%s\n", found.Image.Name, found.Image.Target.Digest) + for _, digest := range digests { + fmt.Fprintf(options.Stdout, "Deleted: %s\n", digest) + } + return true, nil + }, } var errs []string var fatalErr bool for _, req := range args { - n, err := walker.Walk(ctx, req) + var n int + if options.GOptions.KubeHideDupe && options.GOptions.Namespace == "k8s.io" { + n, err = walker.WalkCriRm(ctx, req) + } else { + n, err = walker.Walk(ctx, req) + } if err != nil { fatalErr = true } @@ -112,7 +185,7 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio if !options.Force || fatalErr { return errors.New(msg) } - logrus.Error(msg) + log.G(ctx).Error(msg) } return nil } diff --git a/pkg/cmd/image/save.go b/pkg/cmd/image/save.go index cf4dbf3069e..2b5d6d125ae 100644 --- a/pkg/cmd/image/save.go +++ b/pkg/cmd/image/save.go @@ -20,12 +20,13 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images/archive" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/strutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images/archive" + + "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" ) // Save exports `images` to a `io.Writer` (e.g., a file writer, or os.Stdout) specified by `options.Stdout`. @@ -47,10 +48,16 @@ func Save(ctx context.Context, client *containerd.Client, images []string, optio if found.UniqueImages > 1 { return fmt.Errorf("ambiguous digest ID: multiple IDs found with provided prefix %s", found.Req) } + + // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 + err = EnsureAllContent(ctx, client, found.Image.Name, options.GOptions) + if err != nil { + return err + } + imgName := found.Image.Name - imgDigest := found.Image.Target.Digest.String() - if _, ok := savedImages[imgDigest]; !ok { - savedImages[imgDigest] = struct{}{} + if _, ok := savedImages[imgName]; !ok { + savedImages[imgName] = struct{}{} exportOpts = append(exportOpts, archive.WithImage(imageStore, imgName)) } return nil diff --git a/pkg/cmd/image/tag.go b/pkg/cmd/image/tag.go index 8349fc6c769..5323080f745 100644 --- a/pkg/cmd/image/tag.go +++ b/pkg/cmd/image/tag.go @@ -20,17 +20,19 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/referenceutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" + "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 - imagewalker := &imagewalker.ImageWalker{ + walker := &imagewalker.ImageWalker{ Client: client, OnFound: func(ctx context.Context, found imagewalker.Found) error { if srcName == "" { @@ -39,7 +41,7 @@ func Tag(ctx context.Context, client *containerd.Client, options types.ImageTagO return nil }, } - matchCount, err := imagewalker.Walk(ctx, options.Source) + matchCount, err := walker.Walk(ctx, options.Source) if err != nil { return err } @@ -47,7 +49,7 @@ func Tag(ctx context.Context, client *containerd.Client, options types.ImageTagO return fmt.Errorf("%s: not found", options.Source) } - target, err := referenceutil.ParseDockerRef(options.Target) + parsedReference, err := referenceutil.Parse(options.Target) if err != nil { return err } @@ -58,17 +60,25 @@ func Tag(ctx context.Context, client *containerd.Client, options types.ImageTagO } defer done(ctx) - image, err := imageService.Get(ctx, srcName) + // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 + err = EnsureAllContent(ctx, client, srcName, 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 } - image.Name = target.String() - if _, err = imageService.Create(ctx, image); err != nil { + + img.Name = parsedReference.String() + if _, err = imageService.Create(ctx, img); err != nil { if errdefs.IsAlreadyExists(err) { - if err = imageService.Delete(ctx, image.Name); err != nil { + if err = imageService.Delete(ctx, img.Name); err != nil { return err } - if _, err = imageService.Create(ctx, image); err != nil { + if _, err = imageService.Create(ctx, img); err != nil { return err } } else { diff --git a/pkg/cmd/ipfs/registry_serve.go b/pkg/cmd/ipfs/registry_serve.go index 09d214a4633..09294032c1d 100644 --- a/pkg/cmd/ipfs/registry_serve.go +++ b/pkg/cmd/ipfs/registry_serve.go @@ -21,9 +21,10 @@ import ( "os" "path/filepath" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/ipfs" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/ipfs" ) func RegistryServe(options types.IPFSRegistryServeOptions) error { @@ -47,7 +48,7 @@ func RegistryServe(options types.IPFSRegistryServeOptions) error { if err != nil { return err } - logrus.Infof("serving on %v", options.ListenRegistry) + log.L.Infof("serving on %v", options.ListenRegistry) http.Handle("/", h) return http.ListenAndServe(options.ListenRegistry, nil) } diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index f4f49b0df2e..773bf8edc76 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -17,28 +17,23 @@ package login import ( - "bufio" "context" "errors" "fmt" "io" "net/http" "net/url" - "os" - "strings" - "github.com/containerd/containerd/remotes/docker" - "github.com/containerd/containerd/remotes/docker/config" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" - dockercliconfig "github.com/docker/cli/cli/config" - dockercliconfigtypes "github.com/docker/cli/cli/config/types" - "github.com/docker/docker/api/types/registry" - "github.com/docker/docker/errdefs" - "github.com/sirupsen/logrus" "golang.org/x/net/context/ctxhttp" - "golang.org/x/term" + + "github.com/containerd/containerd/v2/core/remotes/docker" + "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/errdefs" + "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" ) const unencryptedPasswordWarning = `WARNING: Your password will be stored unencrypted in %s. @@ -46,121 +41,90 @@ Configure a credential helper to remove this warning. See https://docs.docker.com/engine/reference/commandline/login/#credentials-store ` -type isFileStore interface { - IsFileStore() bool - GetFilename() string -} - func Login(ctx context.Context, options types.LoginCommandOptions, stdout io.Writer) error { - var serverAddress string - if options.ServerAddress == "" { - serverAddress = dockerconfigresolver.IndexServer - } else { - serverAddress = options.ServerAddress + registryURL, err := dockerconfigresolver.Parse(options.ServerAddress) + if err != nil { + return err + } + + credStore, err := dockerconfigresolver.NewCredentialsStore("") + if err != nil { + return err } var responseIdentityToken string - isDefaultRegistry := serverAddress == dockerconfigresolver.IndexServer - authConfig, err := GetDefaultAuthConfig(options.Username == "" && options.Password == "", serverAddress, isDefaultRegistry) - if authConfig == nil { - authConfig = ®istry.AuthConfig{ServerAddress: serverAddress} - } - if err == nil && authConfig.Username != "" && authConfig.Password != "" { - //login With StoreCreds - responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig) + credentials, err := credStore.Retrieve(registryURL, options.Username == "" && options.Password == "") + credentials.IdentityToken = "" + + if err == nil && credentials.Username != "" && credentials.Password != "" { + responseIdentityToken, err = loginClientSide(ctx, options.GOptions, registryURL, credentials) } - if err != nil || authConfig.Username == "" || authConfig.Password == "" { - err = ConfigureAuthentication(authConfig, options.Username, options.Password) + if err != nil || credentials.Username == "" || credentials.Password == "" { + err = promptUserForAuthentication(credentials, options.Username, options.Password, stdout) if err != nil { return err } - responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig) + responseIdentityToken, err = loginClientSide(ctx, options.GOptions, registryURL, credentials) if err != nil { return err } } if responseIdentityToken != "" { - authConfig.Password = "" - authConfig.IdentityToken = responseIdentityToken - } - - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return err + credentials.Password = "" + credentials.IdentityToken = responseIdentityToken } - creds := dockerConfigFile.GetCredentialsStore(serverAddress) - - store, isFile := creds.(isFileStore) // Display a warning if we're storing the users password (not a token) and credentials store type is file. - if isFile && authConfig.Password != "" { - _, err = fmt.Fprintln(stdout, fmt.Sprintf(unencryptedPasswordWarning, store.GetFilename())) + storageFileLocation := credStore.FileStorageLocation(registryURL) + if storageFileLocation != "" && credentials.Password != "" { + _, err = fmt.Fprintln(stdout, fmt.Sprintf(unencryptedPasswordWarning, storageFileLocation)) if err != nil { return err } } - if err := creds.Store(dockercliconfigtypes.AuthConfig(*(authConfig))); err != nil { + err = credStore.Store(registryURL, credentials) + if err != nil { return fmt.Errorf("error saving credentials: %w", err) } - fmt.Fprintln(stdout, "Login Succeeded") - - return nil -} - -// Code from github.com/docker/cli/cli/command (v20.10.3) -// GetDefaultAuthConfig gets the default auth config given a serverAddress -// If credentials for given serverAddress exists in the credential store, the configuration will be populated with values in it -func GetDefaultAuthConfig(checkCredStore bool, serverAddress string, isDefaultRegistry bool) (*registry.AuthConfig, error) { - if !isDefaultRegistry { - var err error - serverAddress, err = convertToHostname(serverAddress) + // When the port is the https default (443), other clients cannot be expected to necessarily lookup the variants with port + // so save it both with and without port. + // This is the case for at least buildctl: https://github.com/containerd/nerdctl/issues/3748 + if registryURL.Port() == dockerconfigresolver.StandardHTTPSPort { + registryURL.Host = registryURL.Hostname() + err = credStore.Store(registryURL, credentials) if err != nil { - return nil, err + return fmt.Errorf("error saving credentials: %w", err) } } - var authconfig = dockercliconfigtypes.AuthConfig{} - if checkCredStore { - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return nil, err - } - authconfig, err = dockerConfigFile.GetAuthConfig(serverAddress) - if err != nil { - return nil, err - } - } - authconfig.ServerAddress = serverAddress - authconfig.IdentityToken = "" - res := registry.AuthConfig(authconfig) - return &res, nil + + _, err = fmt.Fprintln(stdout, "Login Succeeded") + + return err } -func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptions, auth registry.AuthConfig) (string, error) { - host, err := convertToHostname(auth.ServerAddress) - if err != nil { - return "", err - } +func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptions, registryURL *dockerconfigresolver.RegistryURL, credentials *dockerconfigresolver.Credentials) (string, error) { + host := registryURL.Host var dOpts []dockerconfigresolver.Opt if globalOptions.InsecureRegistry { - logrus.Warnf("skipping verifying HTTPS certs for %q", host) + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", host) dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) } dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(globalOptions.HostsDir)) authCreds := func(acArg string) (string, string, error) { if acArg == host { - if auth.RegistryToken != "" { + if credentials.RegistryToken != "" { // Even containerd/CRI does not support RegistryToken as of v1.4.3, // so, nobody is actually using RegistryToken? - logrus.Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host) + log.G(ctx).Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host) } - return auth.Username, auth.Password, nil + return credentials.Username, credentials.Password, nil } return "", "", fmt.Errorf("expected acArg to be %q, got %q", host, acArg) } @@ -180,13 +144,13 @@ func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptio if err != nil { return "", err } - logrus.Debugf("len(regHosts)=%d", len(regHosts)) + log.G(ctx).Debugf("len(regHosts)=%d", len(regHosts)) if len(regHosts) == 0 { return "", fmt.Errorf("got empty []docker.RegistryHost for %q", host) } for i, rh := range regHosts { err = tryLoginWithRegHost(ctx, rh) - if err != nil && globalOptions.InsecureRegistry && (errutil.IsErrHTTPResponseToHTTPSClient(err) || errutil.IsErrConnectionRefused(err)) { + if err != nil && globalOptions.InsecureRegistry && (errors.Is(err, http.ErrSchemeMismatch) || errutil.IsErrConnectionRefused(err)) { rh.Scheme = "http" err = tryLoginWithRegHost(ctx, rh) } @@ -194,7 +158,7 @@ func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptio if err == nil { return identityToken, nil } - logrus.WithError(err).WithField("i", i).Error("failed to call tryLoginWithRegHost") + log.G(ctx).WithError(err).WithField("i", i).Error("failed to call tryLoginWithRegHost") } return "", err } @@ -248,76 +212,3 @@ func tryLoginWithRegHost(ctx context.Context, rh docker.RegistryHost) error { return errors.New("too many 401 (probably)") } - -func ConfigureAuthentication(authConfig *registry.AuthConfig, username, password string) error { - authConfig.Username = strings.TrimSpace(authConfig.Username) - if username = strings.TrimSpace(username); username == "" { - username = authConfig.Username - } - if username == "" { - fmt.Print("Enter Username: ") - usr, err := readUsername() - if err != nil { - return err - } - username = usr - } - if username == "" { - return fmt.Errorf("error: Username is Required") - } - - if password == "" { - fmt.Print("Enter Password: ") - pwd, err := readPassword() - fmt.Println() - if err != nil { - return err - } - password = pwd - } - if password == "" { - return fmt.Errorf("error: Password is Required") - } - - authConfig.Username = username - authConfig.Password = password - - return nil -} - -func readUsername() (string, error) { - var fd *os.File - if term.IsTerminal(int(os.Stdin.Fd())) { - fd = os.Stdin - } else { - return "", fmt.Errorf("stdin is not a terminal (Hint: use `nerdctl login --username=USERNAME --password-stdin`)") - } - - reader := bufio.NewReader(fd) - username, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("error reading username: %w", err) - } - username = strings.TrimSpace(username) - - return username, nil -} - -func convertToHostname(serverAddress string) (string, error) { - // Ensure that URL contains scheme for a good parsing process - if strings.Contains(serverAddress, "://") { - u, err := url.Parse(serverAddress) - if err != nil { - return "", err - } - serverAddress = u.Host - } else { - u, err := url.Parse("https://" + serverAddress) - if err != nil { - return "", err - } - serverAddress = u.Host - } - - return serverAddress, nil -} diff --git a/pkg/cmd/login/prompt.go b/pkg/cmd/login/prompt.go new file mode 100644 index 00000000000..db1f6554244 --- /dev/null +++ b/pkg/cmd/login/prompt.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 login + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" +) + +var ( + // User did not provide non-empty credentials when prompted for it + ErrUsernameIsRequired = errors.New("username is required") + ErrPasswordIsRequired = errors.New("password is required") + + // System errors - not a terminal, failure to read, etc + ErrReadingUsername = errors.New("unable to read username") + ErrReadingPassword = errors.New("unable to read password") + ErrNotATerminal = errors.New("stdin is not a terminal (Hint: use `nerdctl login --username=USERNAME --password-stdin`)") + ErrCannotAllocateTerminal = errors.New("error allocating terminal") +) + +// promptUserForAuthentication will prompt the user for credentials if needed +// It might error with any of the errors defined above. +func promptUserForAuthentication(credentials *dockerconfigresolver.Credentials, username, password string, stdout io.Writer) error { + var err error + + // If the provided username is empty... + if username = strings.TrimSpace(username); username == "" { + // Use the one we know of (from the store) + username = credentials.Username + // If the one from the store was empty as well, prompt and read the username + if username == "" { + _, _ = fmt.Fprint(stdout, "Enter Username: ") + username, err = readUsername() + if err != nil { + return err + } + + username = strings.TrimSpace(username) + // If it still is empty, that is an error + if username == "" { + return ErrUsernameIsRequired + } + } + } + + // If password was NOT passed along, ask for it + if password == "" { + _, _ = fmt.Fprint(stdout, "Enter Password: ") + password, err = readPassword() + if err != nil { + return err + } + + _, _ = fmt.Fprintln(stdout) + password = strings.TrimSpace(password) + + // If nothing was provided, error out + if password == "" { + return ErrPasswordIsRequired + } + } + + // Attach non-empty credentials to the auth object and return + credentials.Username = username + credentials.Password = password + + return nil +} + +// readUsername will try to read from user input +// It might error with: +// - ErrNotATerminal +// - ErrReadingUsername +func readUsername() (string, error) { + fd := os.Stdin + if !term.IsTerminal(int(fd.Fd())) { + return "", ErrNotATerminal + } + + username, err := bufio.NewReader(fd).ReadString('\n') + if err != nil { + return "", errors.Join(ErrReadingUsername, err) + } + + return strings.TrimSpace(username), nil +} diff --git a/pkg/cmd/login/login_unix.go b/pkg/cmd/login/prompt_unix.go similarity index 72% rename from pkg/cmd/login/login_unix.go rename to pkg/cmd/login/prompt_unix.go index ee536be1721..69529473f6d 100644 --- a/pkg/cmd/login/login_unix.go +++ b/pkg/cmd/login/prompt_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -19,28 +19,34 @@ package login import ( - "fmt" + "errors" "os" "syscall" "golang.org/x/term" + + "github.com/containerd/log" ) func readPassword() (string, error) { - var fd int - if term.IsTerminal(syscall.Stdin) { - fd = syscall.Stdin - } else { + fd := syscall.Stdin + if !term.IsTerminal(fd) { tty, err := os.Open("/dev/tty") if err != nil { - return "", fmt.Errorf("error allocating terminal: %w", err) + return "", errors.Join(ErrCannotAllocateTerminal, err) } - defer tty.Close() + defer func() { + err = tty.Close() + if err != nil { + log.L.WithError(err).Error("failed closing tty") + } + }() fd = int(tty.Fd()) } + bytePassword, err := term.ReadPassword(fd) if err != nil { - return "", fmt.Errorf("error reading password: %w", err) + return "", errors.Join(ErrReadingPassword, err) } return string(bytePassword), nil diff --git a/pkg/cmd/login/login_windows.go b/pkg/cmd/login/prompt_windows.go similarity index 79% rename from pkg/cmd/login/login_windows.go rename to pkg/cmd/login/prompt_windows.go index 89c3834fb92..913e6ff5f98 100644 --- a/pkg/cmd/login/login_windows.go +++ b/pkg/cmd/login/prompt_windows.go @@ -17,22 +17,21 @@ package login import ( - "fmt" + "errors" "syscall" "golang.org/x/term" ) func readPassword() (string, error) { - var fd int - if term.IsTerminal(int(syscall.Stdin)) { - fd = int(syscall.Stdin) - } else { - return "", fmt.Errorf("error allocating terminal") + fd := int(syscall.Stdin) + if !term.IsTerminal(fd) { + return "", ErrNotATerminal } + bytePassword, err := term.ReadPassword(fd) if err != nil { - return "", fmt.Errorf("error reading password: %w", err) + return "", errors.Join(ErrReadingPassword, err) } return string(bytePassword), nil diff --git a/pkg/cmd/logout/logout.go b/pkg/cmd/logout/logout.go new file mode 100644 index 00000000000..99a4b77f852 --- /dev/null +++ b/pkg/cmd/logout/logout.go @@ -0,0 +1,46 @@ +/* + 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 logout + +import ( + "context" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" +) + +func Logout(ctx context.Context, logoutServer string) (map[string]error, error) { + reg, err := dockerconfigresolver.Parse(logoutServer) + if err != nil { + return nil, err + } + + credentialsStore, err := dockerconfigresolver.NewCredentialsStore("") + if err != nil { + return nil, err + } + + return credentialsStore.Erase(reg) +} + +func ShellCompletion() ([]string, error) { + credentialsStore, err := dockerconfigresolver.NewCredentialsStore("") + if err != nil { + return nil, err + } + + return credentialsStore.ShellCompletion(), nil +} diff --git a/pkg/cmd/namespace/create.go b/pkg/cmd/namespace/create.go index f23765dcbab..f07b9f007e6 100644 --- a/pkg/cmd/namespace/create.go +++ b/pkg/cmd/namespace/create.go @@ -19,8 +19,9 @@ package namespace import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) func Create(ctx context.Context, client *containerd.Client, namespace string, options types.NamespaceCreateOptions) error { diff --git a/pkg/cmd/namespace/inspect.go b/pkg/cmd/namespace/inspect.go index 99341ca03d2..3a7a4932815 100644 --- a/pkg/cmd/namespace/inspect.go +++ b/pkg/cmd/namespace/inspect.go @@ -19,11 +19,12 @@ package namespace import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/containerd/namespaces" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/namespaces" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) func Inspect(ctx context.Context, client *containerd.Client, inspectedNamespaces []string, options types.NamespaceInspectOptions) error { diff --git a/pkg/cmd/namespace/namespace_freebsd.go b/pkg/cmd/namespace/namespace_freebsd.go index a5448749201..a3a45d59168 100644 --- a/pkg/cmd/namespace/namespace_freebsd.go +++ b/pkg/cmd/namespace/namespace_freebsd.go @@ -17,7 +17,7 @@ package namespace import ( - "github.com/containerd/containerd/namespaces" + "github.com/containerd/containerd/v2/pkg/namespaces" ) func namespaceDeleteOpts(cgroup bool) ([]namespaces.DeleteOpts, error) { diff --git a/pkg/cmd/namespace/namespace_linux.go b/pkg/cmd/namespace/namespace_linux.go index e13b331fa95..96255cdcb5e 100644 --- a/pkg/cmd/namespace/namespace_linux.go +++ b/pkg/cmd/namespace/namespace_linux.go @@ -17,8 +17,8 @@ package namespace import ( - "github.com/containerd/containerd/namespaces" - "github.com/containerd/containerd/runtime/opts" + "github.com/containerd/containerd/v2/core/runtime/opts" + "github.com/containerd/containerd/v2/pkg/namespaces" ) func namespaceDeleteOpts(cgroup bool) ([]namespaces.DeleteOpts, error) { diff --git a/pkg/cmd/namespace/namespace_windows.go b/pkg/cmd/namespace/namespace_windows.go index a5448749201..a3a45d59168 100644 --- a/pkg/cmd/namespace/namespace_windows.go +++ b/pkg/cmd/namespace/namespace_windows.go @@ -17,7 +17,7 @@ package namespace import ( - "github.com/containerd/containerd/namespaces" + "github.com/containerd/containerd/v2/pkg/namespaces" ) func namespaceDeleteOpts(cgroup bool) ([]namespaces.DeleteOpts, error) { diff --git a/pkg/cmd/namespace/remove.go b/pkg/cmd/namespace/remove.go index 130e302b9b2..81850be854e 100644 --- a/pkg/cmd/namespace/remove.go +++ b/pkg/cmd/namespace/remove.go @@ -20,10 +20,11 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/log" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) func Remove(ctx context.Context, client *containerd.Client, deletedNamespaces []string, options types.NamespaceRemoveOptions) error { @@ -43,7 +44,7 @@ func Remove(ctx context.Context, client *containerd.Client, deletedNamespaces [] continue } } - _, err := fmt.Fprintf(options.Stdout, "%s\n", target) + _, err := fmt.Fprintln(options.Stdout, target) return err } return exitErr diff --git a/pkg/cmd/namespace/update.go b/pkg/cmd/namespace/update.go index a8b4eb31aef..63d2d8a5971 100644 --- a/pkg/cmd/namespace/update.go +++ b/pkg/cmd/namespace/update.go @@ -19,8 +19,9 @@ package namespace import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) func Update(ctx context.Context, client *containerd.Client, namespace string, options types.NamespaceUpdateOptions) error { diff --git a/pkg/cmd/network/create.go b/pkg/cmd/network/create.go index f592a5ee62b..dc62875863e 100644 --- a/pkg/cmd/network/create.go +++ b/pkg/cmd/network/create.go @@ -20,29 +20,31 @@ import ( "fmt" "io" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) func Create(options types.NetworkCreateOptions, stdout io.Writer) error { - if options.CreateOptions.Subnet == "" { - if options.CreateOptions.Gateway != "" || options.CreateOptions.IPRange != "" { + if len(options.Subnets) == 0 { + if options.Gateway != "" || options.IPRange != "" { return fmt.Errorf("cannot set gateway or ip-range without subnet, specify --subnet manually") } + options.Subnets = []string{""} } - e, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath) + e, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath, netutil.WithNamespace(options.GOptions.Namespace)) if err != nil { return err } - net, err := e.CreateNetwork(options.CreateOptions) + net, err := e.CreateNetwork(options) if err != nil { if errdefs.IsAlreadyExists(err) { - return fmt.Errorf("network with name %s already exists", options.CreateOptions.Name) + return fmt.Errorf("network with name %s already exists", options.Name) } return err } - _, err = fmt.Fprintf(stdout, "%s\n", *net.NerdctlID) + _, err = fmt.Fprintln(stdout, *net.NerdctlID) return err } diff --git a/pkg/cmd/network/inspect.go b/pkg/cmd/network/inspect.go index eb226ac1ec4..5fae28028f3 100644 --- a/pkg/cmd/network/inspect.go +++ b/pkg/cmd/network/inspect.go @@ -19,61 +19,71 @@ package network import ( "context" "encoding/json" + "errors" "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/idutil/netwalker" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/sirupsen/logrus" + "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/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) func Inspect(ctx context.Context, options types.NetworkInspectOptions) error { - globalOptions := options.GOptions - e, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath) + if options.Mode != "native" && options.Mode != "dockercompat" { + return fmt.Errorf("unknown mode %q", options.Mode) + } + cniEnv, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath, netutil.WithNamespace(options.GOptions.Namespace)) if err != nil { return err } - if options.Mode != "native" && options.Mode != "dockercompat" { - return fmt.Errorf("unknown mode %q", options.Mode) - } var result []interface{} - walker := netwalker.NetworkWalker{ - Client: e, - OnFound: func(ctx context.Context, found netwalker.Found) error { - if found.MatchCount > 1 { - return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) - } - r := &native.Network{ - CNI: json.RawMessage(found.Network.Bytes), - NerdctlID: found.Network.NerdctlID, - NerdctlLabels: found.Network.NerdctlLabels, - File: found.Network.File, - } - switch options.Mode { - case "native": - result = append(result, r) - case "dockercompat": - compat, err := dockercompat.NetworkFromNative(r) - if err != nil { - return err - } - result = append(result, compat) + netLists, errs := cniEnv.ListNetworksMatch(options.Networks, true) + + for req, netList := range netLists { + if len(netList) > 1 { + errs = append(errs, fmt.Errorf("multiple IDs found with provided prefix: %s", req)) + continue + } + if len(netList) == 0 { + errs = append(errs, fmt.Errorf("no network found matching: %s", req)) + continue + } + network := netList[0] + r := &native.Network{ + CNI: json.RawMessage(network.Bytes), + NerdctlID: network.NerdctlID, + NerdctlLabels: network.NerdctlLabels, + File: network.File, + } + switch options.Mode { + case "native": + result = append(result, r) + case "dockercompat": + compat, err := dockercompat.NetworkFromNative(r) + if err != nil { + return err } - return nil - }, + result = append(result, compat) + } } - // `network inspect` doesn't support pseudo network. - err = walker.WalkAll(ctx, options.Networks, true, false) if len(result) > 0 { if formatErr := formatter.FormatSlice(options.Format, options.Stdout, result); formatErr != nil { - logrus.Error(formatErr) + log.G(ctx).Error(formatErr) } + err = nil + } else { + err = errors.New("unable to find any network matching the provided request") + } + + for _, unErr := range errs { + log.G(ctx).Error(unErr) } + return err } diff --git a/pkg/cmd/network/list.go b/pkg/cmd/network/list.go index 3e0cfc54030..b2075356d8c 100644 --- a/pkg/cmd/network/list.go +++ b/pkg/cmd/network/list.go @@ -21,12 +21,13 @@ import ( "context" "errors" "fmt" + "strings" "text/tabwriter" "text/template" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) type networkPrintable struct { @@ -42,6 +43,7 @@ func List(ctx context.Context, options types.NetworkListOptions) error { quiet := options.Quiet format := options.Format w := options.Stdout + filters := options.Filters var tmpl *template.Template switch format { @@ -63,7 +65,7 @@ func List(ctx context.Context, options types.NetworkListOptions) error { } } - e, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath) + e, err := netutil.NewCNIEnv(globalOptions.CNIPath, globalOptions.CNINetConfPath, netutil.WithNamespace(options.GOptions.Namespace)) if err != nil { return err } @@ -71,6 +73,21 @@ func List(ctx context.Context, options types.NetworkListOptions) error { if err != nil { return err } + + labelFilterFuncs, nameFilterFuncs, err := getNetworkFilterFuncs(filters) + if err != nil { + return err + } + if len(filters) > 0 { + filtered := make([]*netutil.NetworkConfig, 0) + for _, net := range netConfigs { + if networkMatchesFilter(net, labelFilterFuncs, nameFilterFuncs) { + filtered = append(filtered, net) + } + } + netConfigs = filtered + } + pp := make([]networkPrintable, len(netConfigs)) for i, n := range netConfigs { p := networkPrintable{ @@ -90,14 +107,16 @@ func List(ctx context.Context, options types.NetworkListOptions) error { } // append pseudo networks - pp = append(pp, []networkPrintable{ - { - Name: "host", - }, - { - Name: "none", - }, - }...) + if len(filters) == 0 { // filter a pseudo networks is meanless + pp = append(pp, []networkPrintable{ + { + Name: "host", + }, + { + Name: "none", + }, + }...) + } for _, p := range pp { if tmpl != nil { @@ -105,7 +124,7 @@ func List(ctx context.Context, options types.NetworkListOptions) error { if err := tmpl.Execute(&b, p); err != nil { return err } - if _, err = fmt.Fprintf(w, b.String()+"\n"); err != nil { + if _, err = fmt.Fprintln(w, b.String()); err != nil { return err } } else if quiet { @@ -121,3 +140,56 @@ func List(ctx context.Context, options types.NetworkListOptions) error { } return nil } + +func getNetworkFilterFuncs(filters []string) ([]func(*map[string]string) bool, []func(string) bool, error) { + labelFilterFuncs := make([]func(*map[string]string) bool, 0) + nameFilterFuncs := make([]func(string) bool, 0) + + for _, filter := range filters { + if strings.HasPrefix(filter, "name") || strings.HasPrefix(filter, "label") { + subs := strings.SplitN(filter, "=", 2) + if len(subs) < 2 { + continue + } + switch subs[0] { + case "name": + nameFilterFuncs = append(nameFilterFuncs, func(name string) bool { + return strings.Contains(name, subs[1]) + }) + 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] + } + labelFilterFuncs = append(labelFilterFuncs, func(labels *map[string]string) bool { + if labels == nil { + return false + } + val, ok := (*labels)[k] + if !ok || (hasValue && val != v) { + return false + } + return true + }) + } + continue + } + } + return labelFilterFuncs, nameFilterFuncs, nil +} + +func networkMatchesFilter(net *netutil.NetworkConfig, labelFilterFuncs []func(*map[string]string) bool, nameFilterFuncs []func(string) bool) bool { + for _, labelFilterFunc := range labelFilterFuncs { + if !labelFilterFunc(net.NerdctlLabels) { + return false + } + } + for _, nameFilterFunc := range nameFilterFuncs { + if !nameFilterFunc(net.Name) { + return false + } + } + + return true +} diff --git a/pkg/cmd/network/prune.go b/pkg/cmd/network/prune.go index 84b59a8776c..d88442160b1 100644 --- a/pkg/cmd/network/prune.go +++ b/pkg/cmd/network/prune.go @@ -20,15 +20,16 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/sirupsen/logrus" + 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/netutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func Prune(ctx context.Context, client *containerd.Client, options types.NetworkPruneOptions) error { - e, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath) + e, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath, netutil.WithNamespace(options.GOptions.Namespace)) if err != nil { return err } @@ -55,7 +56,7 @@ func Prune(ctx context.Context, client *containerd.Client, options types.Network continue } if err := e.RemoveNetwork(net); err != nil { - logrus.WithError(err).Errorf("failed to remove network %s", net.Name) + log.G(ctx).WithError(err).Errorf("failed to remove network %s", net.Name) continue } removedNetworks = append(removedNetworks, net.Name) diff --git a/pkg/cmd/network/remove.go b/pkg/cmd/network/remove.go index 76da1eeabca..41731d3f40c 100644 --- a/pkg/cmd/network/remove.go +++ b/pkg/cmd/network/remove.go @@ -18,16 +18,18 @@ package network import ( "context" + "errors" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/idutil/netwalker" - "github.com/containerd/nerdctl/pkg/netutil" + 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/netutil" ) func Remove(ctx context.Context, client *containerd.Client, options types.NetworkRemoveOptions) error { - e, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath) + cniEnv, err := netutil.NewCNIEnv(options.GOptions.CNIPath, options.GOptions.CNINetConfPath, netutil.WithNamespace(options.GOptions.Namespace)) if err != nil { return err } @@ -37,28 +39,52 @@ func Remove(ctx context.Context, client *containerd.Client, options types.Networ return err } - walker := netwalker.NetworkWalker{ - Client: e, - OnFound: func(ctx context.Context, found netwalker.Found) error { - if found.MatchCount > 1 { - return fmt.Errorf("multiple IDs found with provided prefix: %s", found.Req) - } - if value, ok := usedNetworkInfo[found.Network.Name]; ok { - return fmt.Errorf("network %q is in use by container %q", found.Req, value) - } - if found.Network.NerdctlID == nil { - return fmt.Errorf("%s is managed outside nerdctl and cannot be removed", found.Req) - } - if found.Network.File == "" { - return fmt.Errorf("%s is a pre-defined network and cannot be removed", found.Req) - } - if err := e.RemoveNetwork(found.Network); err != nil { - return err - } - fmt.Fprintln(options.Stdout, found.Req) - return nil - }, + var result []string + netLists, errs := cniEnv.ListNetworksMatch(options.Networks, false) + + for req, netList := range netLists { + if len(netList) > 1 { + errs = append(errs, fmt.Errorf("multiple IDs found with provided prefix: %s", req)) + continue + } + if len(netList) == 0 { + errs = append(errs, fmt.Errorf("no network found matching: %s", req)) + continue + } + network := netList[0] + if value, ok := usedNetworkInfo[network.Name]; ok { + errs = append(errs, fmt.Errorf("network %q is in use by container %q", req, value)) + continue + } + if network.Name == "bridge" { + errs = append(errs, errors.New("cannot remove pre-defined network bridge")) + continue + } + if network.File == "" { + errs = append(errs, fmt.Errorf("%s is a pre-defined network and cannot be removed", req)) + continue + } + if network.NerdctlID == nil { + errs = append(errs, fmt.Errorf("%s is managed outside nerdctl and cannot be removed", req)) + continue + } + if err := cniEnv.RemoveNetwork(network); err != nil { + errs = append(errs, err) + } else { + result = append(result, req) + } + } + for _, unErr := range errs { + log.G(ctx).Error(unErr) + } + if len(result) > 0 { + for _, id := range result { + fmt.Fprintln(options.Stdout, id) + } + err = nil + } else { + err = errors.New("no network could be removed") } - return walker.WalkAll(ctx, options.Networks, true, false) + return err } diff --git a/pkg/cmd/system/events.go b/pkg/cmd/system/events.go index 22083bfe2a6..a544f071b57 100644 --- a/pkg/cmd/system/events.go +++ b/pkg/cmd/system/events.go @@ -22,26 +22,128 @@ import ( "encoding/json" "errors" "fmt" + "strings" "text/template" "time" - "github.com/containerd/containerd" _ "github.com/containerd/containerd/api/events" // Register grpc event types - "github.com/containerd/containerd/events" - "github.com/containerd/containerd/log" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/events" + "github.com/containerd/log" "github.com/containerd/typeurl/v2" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" ) // EventOut contains information about an event. type EventOut struct { Timestamp time.Time + ID string Namespace string Topic string + Status Status Event string } +type Status string + +const ( + START Status = "start" + UNKNOWN Status = "unknown" +) + +var statuses = [...]Status{START, UNKNOWN} + +func isStatus(status string) bool { + status = strings.ToLower(status) + + for _, supportedStatus := range statuses { + if string(supportedStatus) == status { + return true + } + } + + return false +} + +func TopicToStatus(topic string) Status { + if strings.Contains(strings.ToLower(topic), string(START)) { + return START + } + + return UNKNOWN +} + +// EventFilter for filtering events +type EventFilter func(*EventOut) bool + +// generateEventFilter is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L11 +func generateEventFilter(filter, filterValue string) (func(e *EventOut) bool, error) { + switch strings.ToUpper(filter) { + case "EVENT", "STATUS": + return func(e *EventOut) bool { + if !isStatus(string(e.Status)) { + return false + } + + return strings.EqualFold(string(e.Status), filterValue) + }, nil + } + + return nil, fmt.Errorf("%s is an invalid or unsupported filter", filter) +} + +// parseFilter is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L96 +func parseFilter(filter string) (string, string, error) { + filterSplit := strings.SplitN(filter, "=", 2) + if len(filterSplit) != 2 { + return "", "", fmt.Errorf("%s is an invalid filter", filter) + } + return filterSplit[0], filterSplit[1], nil +} + +// applyFilters is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L106 +func applyFilters(event *EventOut, filterMap map[string][]EventFilter) bool { + for _, filters := range filterMap { + match := false + for _, filter := range filters { + if filter(event) { + match = true + break + } + } + if !match { + return false + } + } + return true +} + +// generateEventFilters is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L11 +func generateEventFilters(filters []string) (map[string][]EventFilter, error) { + filterMap := make(map[string][]EventFilter) + for _, filter := range filters { + key, val, err := parseFilter(filter) + if err != nil { + return nil, err + } + filterFunc, err := generateEventFilter(key, val) + if err != nil { + return nil, err + } + filterSlice := filterMap[key] + filterSlice = append(filterSlice, filterFunc) + filterMap[key] = filterSlice + } + + return filterMap, nil +} + // Events is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/events/events.go func Events(ctx context.Context, client *containerd.Client, options types.SystemEventsOptions) error { eventsClient := client.EventService() @@ -59,6 +161,10 @@ func Events(ctx context.Context, client *containerd.Client, options types.System return err } } + filterMap, err := generateEventFilters(options.Filters) + if err != nil { + return err + } for { var e *events.Envelope select { @@ -68,6 +174,7 @@ func Events(ctx context.Context, client *containerd.Client, options types.System } if e != nil { var out []byte + var id string if e.Event != nil { v, err := typeurl.UnmarshalAny(e.Event) if err != nil { @@ -80,26 +187,41 @@ func Events(ctx context.Context, client *containerd.Client, options types.System continue } } - if tmpl != nil { - out := EventOut{e.Timestamp, e.Namespace, e.Topic, string(out)} - var b bytes.Buffer - if err := tmpl.Execute(&b, out); err != nil { - return err - } - if _, err := fmt.Fprintln(options.Stdout, b.String()+"\n"); err != nil { - return err - } + var data map[string]interface{} + err := json.Unmarshal(out, &data) + if err != nil { + log.G(ctx).WithError(err).Warn("cannot marshal Any into JSON") } else { - if _, err := fmt.Fprintln( - options.Stdout, - e.Timestamp, - e.Namespace, - e.Topic, - string(out), - ); err != nil { - return err + _, ok := data["container_id"] + if ok { + id = data["container_id"].(string) } } + + eOut := EventOut{e.Timestamp, id, e.Namespace, e.Topic, TopicToStatus(e.Topic), string(out)} + match := applyFilters(&eOut, filterMap) + if match { + if tmpl != nil { + var b bytes.Buffer + if err := tmpl.Execute(&b, eOut); err != nil { + return err + } + if _, err := fmt.Fprintln(options.Stdout, b.String()+"\n"); err != nil { + return err + } + } else { + if _, err := fmt.Fprintln( + options.Stdout, + e.Timestamp, + e.Namespace, + e.Topic, + string(out), + ); err != nil { + return err + } + } + } + } } } diff --git a/pkg/cmd/system/info.go b/pkg/cmd/system/info.go index 04dfe1b1df3..183fc577979 100644 --- a/pkg/cmd/system/info.go +++ b/pkg/cmd/system/info.go @@ -24,20 +24,22 @@ import ( "strings" "text/template" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" + "github.com/docker/go-units" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/containerd/containerd/api/services/introspection/v1" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/docker/go-units" - "github.com/sirupsen/logrus" + 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/formatter" + "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/logging" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) func Info(ctx context.Context, client *containerd.Client, options types.SystemInfoOptions) error { @@ -71,6 +73,7 @@ func Info(ctx context.Context, client *containerd.Client, options types.SystemIn if err != nil { return err } + infoCompat.Plugins.Log = logging.Drivers() default: return fmt.Errorf("unknown mode %q", options.Mode) } @@ -84,7 +87,7 @@ func Info(ctx context.Context, client *containerd.Client, options types.SystemIn if err := tmpl.Execute(w, x); err != nil { return err } - _, err = fmt.Fprintf(w, "\n") + _, err = fmt.Fprintln(w) return err } @@ -125,12 +128,12 @@ func prettyPrintInfoNative(w io.Writer, info *native.Info) error { } sorter := func(x []*introspection.Plugin) func(int, int) bool { return func(i, j int) bool { - return x[i].Type+"."+x[j].ID < x[j].Type+"."+x[j].ID + return x[i].Type+"."+x[i].ID < x[j].Type+"."+x[j].ID } } sort.Slice(enabledPlugins, sorter(enabledPlugins)) sort.Slice(disabledPlugins, sorter(disabledPlugins)) - fmt.Fprintf(w, "containerd Plugins:\n") + fmt.Fprintln(w, "containerd Plugins:") for _, f := range enabledPlugins { fmt.Fprintf(w, " - %s.%s\n", f.Type, f.ID) } @@ -147,27 +150,59 @@ func prettyPrintInfoDockerCompat(stdout io.Writer, stderr io.Writer, info *docke fmt.Fprintf(w, "Client:\n") fmt.Fprintf(w, " Namespace:\t%s\n", globalOptions.Namespace) fmt.Fprintf(w, " Debug Mode:\t%v\n", debug) - fmt.Fprintf(w, "\n") + fmt.Fprintln(w) fmt.Fprintf(w, "Server:\n") fmt.Fprintf(w, " Server Version: %s\n", info.ServerVersion) // Storage Driver is not really Server concept for nerdctl, but mimics `docker info` output fmt.Fprintf(w, " Storage Driver: %s\n", info.Driver) fmt.Fprintf(w, " Logging Driver: %s\n", info.LoggingDriver) - fmt.Fprintf(w, " Cgroup Driver: %s\n", info.CgroupDriver) - fmt.Fprintf(w, " Cgroup Version: %s\n", info.CgroupVersion) + printF(w, " Cgroup Driver: ", info.CgroupDriver) + printF(w, " Cgroup Version: ", info.CgroupVersion) fmt.Fprintf(w, " Plugins:\n") - fmt.Fprintf(w, " Log: %s\n", strings.Join(info.Plugins.Log, " ")) + fmt.Fprintf(w, " Log: %s\n", strings.Join(info.Plugins.Log, " ")) fmt.Fprintf(w, " Storage: %s\n", strings.Join(info.Plugins.Storage, " ")) + + // print Security options + printSecurityOptions(w, info.SecurityOptions) + + fmt.Fprintf(w, " Kernel Version: %s\n", info.KernelVersion) + fmt.Fprintf(w, " Operating System: %s\n", info.OperatingSystem) + fmt.Fprintf(w, " OSType: %s\n", info.OSType) + fmt.Fprintf(w, " Architecture: %s\n", info.Architecture) + fmt.Fprintf(w, " CPUs: %d\n", info.NCPU) + fmt.Fprintf(w, " Total Memory: %s\n", units.BytesSize(float64(info.MemTotal))) + fmt.Fprintf(w, " Name: %s\n", info.Name) + fmt.Fprintf(w, " ID: %s\n", info.ID) + + fmt.Fprintln(w) + if len(info.Warnings) > 0 { + fmt.Fprintln(stderr, strings.Join(info.Warnings, "\n")) + } + return nil +} + +func printF(w io.Writer, label string, dockerCompatInfo string) { + if dockerCompatInfo == "" { + return + } + fmt.Fprintf(w, "%s%s\n", label, dockerCompatInfo) +} + +func printSecurityOptions(w io.Writer, securityOptions []string) { + if len(securityOptions) == 0 { + return + } + fmt.Fprintf(w, " Security Options:\n") - for _, s := range info.SecurityOptions { + for _, s := range securityOptions { m, err := strutil.ParseCSVMap(s) if err != nil { - logrus.WithError(err).Warnf("unparsable security option %q", s) + log.L.WithError(err).Warnf("unparsable security option %q", s) continue } name := m["name"] if name == "" { - logrus.Warnf("unparsable security option %q", s) + log.L.Warnf("unparsable security option %q", s) continue } fmt.Fprintf(w, " %s\n", name) @@ -178,18 +213,4 @@ func prettyPrintInfoDockerCompat(stdout io.Writer, stderr io.Writer, info *docke fmt.Fprintf(w, " %s: %s\n", cases.Title(language.English).String(k), v) } } - fmt.Fprintf(w, " Kernel Version: %s\n", info.KernelVersion) - fmt.Fprintf(w, " Operating System: %s\n", info.OperatingSystem) - fmt.Fprintf(w, " OSType: %s\n", info.OSType) - fmt.Fprintf(w, " Architecture: %s\n", info.Architecture) - fmt.Fprintf(w, " CPUs: %d\n", info.NCPU) - fmt.Fprintf(w, " Total Memory: %s\n", units.BytesSize(float64(info.MemTotal))) - fmt.Fprintf(w, " Name: %s\n", info.Name) - fmt.Fprintf(w, " ID: %s\n", info.ID) - - fmt.Fprintln(w) - if len(info.Warnings) > 0 { - fmt.Fprintln(stderr, strings.Join(info.Warnings, "\n")) - } - return nil } diff --git a/pkg/cmd/system/prune.go b/pkg/cmd/system/prune.go index cc3446a234e..19b48ef07c6 100644 --- a/pkg/cmd/system/prune.go +++ b/pkg/cmd/system/prune.go @@ -20,13 +20,14 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/cmd/builder" - "github.com/containerd/nerdctl/pkg/cmd/container" - "github.com/containerd/nerdctl/pkg/cmd/image" - "github.com/containerd/nerdctl/pkg/cmd/network" - "github.com/containerd/nerdctl/pkg/cmd/volume" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/builder" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/cmd/network" + "github.com/containerd/nerdctl/v2/pkg/cmd/volume" ) // Prune will remove all unused containers, networks, @@ -48,6 +49,7 @@ func Prune(ctx context.Context, client *containerd.Client, options types.SystemP if options.Volumes { if err := volume.Prune(ctx, client, types.VolumePruneOptions{ GOptions: options.GOptions, + All: false, Force: true, Stdout: options.Stdout, }); err != nil { diff --git a/pkg/cmd/volume/create.go b/pkg/cmd/volume/create.go index 791ac514b81..5aac0ce0486 100644 --- a/pkg/cmd/volume/create.go +++ b/pkg/cmd/volume/create.go @@ -19,23 +19,28 @@ package volume import ( "fmt" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/strutil" + "github.com/docker/docker/pkg/stringid" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) -func Create(name string, options types.VolumeCreateOptions) error { - if err := identifiers.Validate(name); err != nil { - return fmt.Errorf("malformed name %s: %w", name, err) +func Create(name string, options types.VolumeCreateOptions) (*native.Volume, error) { + if name == "" { + name = stringid.GenerateRandomID() + options.Labels = append(options.Labels, labels.AnonymousVolumes+"=") } volStore, err := Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) if err != nil { - return err + return nil, err } labels := strutil.DedupeStrSlice(options.Labels) - if _, err := volStore.Create(name, labels); err != nil { - return err + vol, err := volStore.Create(name, labels) + if err != nil { + return nil, err } - fmt.Fprintf(options.Stdout, "%s\n", name) - return nil + fmt.Fprintln(options.Stdout, name) + return vol, nil } diff --git a/pkg/cmd/volume/inspect.go b/pkg/cmd/volume/inspect.go index 78221c6bd56..8369a6c9ea8 100644 --- a/pkg/cmd/volume/inspect.go +++ b/pkg/cmd/volume/inspect.go @@ -17,23 +17,41 @@ package volume import ( - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" + "context" + "errors" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/formatter" ) -func Inspect(volumes []string, options types.VolumeInspectOptions) error { +func Inspect(ctx context.Context, volumes []string, options types.VolumeInspectOptions) error { volStore, err := Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) if err != nil { return err } - result := make([]interface{}, len(volumes)) + result := []interface{}{} - for i, name := range volumes { + warns := []error{} + for _, name := range volumes { var vol, err = volStore.Get(name, options.Size) if err != nil { - return err + warns = append(warns, err) + continue } - result[i] = vol + result = append(result, vol) + } + err = formatter.FormatSlice(options.Format, options.Stdout, result) + if err != nil { + return err + } + for _, warn := range warns { + log.G(ctx).Warn(warn) + } + + if len(warns) != 0 { + return errors.New("some volumes could not be inspected") } - return formatter.FormatSlice(options.Format, options.Stdout, result) + return nil } diff --git a/pkg/cmd/volume/list.go b/pkg/cmd/volume/list.go index a026af5642c..bb0654ba5b2 100644 --- a/pkg/cmd/volume/list.go +++ b/pkg/cmd/volume/list.go @@ -25,11 +25,12 @@ import ( "text/tabwriter" "text/template" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/pkg/progress" + "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/inspecttypes/native" ) type volumePrintable struct { @@ -44,16 +45,16 @@ type volumePrintable struct { func List(options types.VolumeListOptions) error { if options.Quiet && options.Size { - logrus.Warn("cannot use --size and --quiet together, ignoring --size") + log.L.Warn("cannot use --size and --quiet together, ignoring --size") options.Size = false } sizeFilter := hasSizeFilter(options.Filters) if sizeFilter && options.Quiet { - logrus.Warn("cannot use --filter=size and --quiet together, ignoring --filter=size") + log.L.Warn("cannot use --filter=size and --quiet together, ignoring --filter=size") options.Filters = removeSizeFilters(options.Filters) } if sizeFilter && !options.Size { - logrus.Warn("should use --filter=size and --size together") + log.L.Warn("should use --filter=size and --size together") options.Size = true } @@ -134,7 +135,7 @@ func lsPrintOutput(vols map[string]native.Volume, options types.VolumeListOption if err := tmpl.Execute(&b, p); err != nil { return err } - if _, err := fmt.Fprintf(w, b.String()+"\n"); err != nil { + if _, err := fmt.Fprintln(w, b.String()); err != nil { return err } } else if options.Quiet { @@ -268,15 +269,28 @@ func volumeMatchesFilter(vol native.Volume, labelFilterFuncs []func(*map[string] return false } } - for _, nameFilterFunc := range nameFilterFuncs { - if !nameFilterFunc(vol.Name) { - return false - } + + if !anyMatch(vol.Name, nameFilterFuncs) { + return false } + for _, sizeFilterFunc := range sizeFilterFuncs { if !sizeFilterFunc(vol.Size) { return false } } + return true } + +func anyMatch[T any](vol T, filters []func(T) bool) bool { + if len(filters) == 0 { + return true + } + for _, f := range filters { + if f(vol) { + return true + } + } + return false +} diff --git a/pkg/cmd/volume/prune.go b/pkg/cmd/volume/prune.go index 06cd202e955..71e6d20e779 100644 --- a/pkg/cmd/volume/prune.go +++ b/pkg/cmd/volume/prune.go @@ -19,46 +19,66 @@ package volume import ( "context" "fmt" + "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func Prune(ctx context.Context, client *containerd.Client, options types.VolumePruneOptions) error { + // Get the volume store and lock it until we are done. + // This will prevent racing new containers from being created or removed until we are done with the cleanup of volumes volStore, err := Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) if err != nil { return err } - volumes, err := volStore.List(false) - if err != nil { - return err - } - containers, err := client.Containers(ctx) - if err != nil { - return err - } - usedVolumes, err := usedVolumes(ctx, containers) - if err != nil { - return err - } - var removeNames []string // nolint: prealloc - for _, volume := range volumes { - if _, ok := usedVolumes[volume.Name]; ok { - continue + var toRemove []string // nolint: prealloc + + err = volStore.Prune(func(volumes []*native.Volume) ([]string, error) { + // Get containers and see which volumes are used + containers, err := client.Containers(ctx) + if err != nil { + return nil, err } - removeNames = append(removeNames, volume.Name) - } - removedNames, err := volStore.Remove(removeNames) + + usedVolumesList, err := usedVolumes(ctx, containers) + if err != nil { + return nil, err + } + + for _, volume := range volumes { + if _, ok := usedVolumesList[volume.Name]; ok { + continue + } + if !options.All { + if volume.Labels == nil { + continue + } + val, ok := (*volume.Labels)[labels.AnonymousVolumes] + // skip the named volume and only remove the anonymous volume + if !ok || val != "" { + continue + } + } + toRemove = append(toRemove, volume.Name) + } + + return toRemove, nil + }) + if err != nil { return err } - if len(removedNames) > 0 { + + if len(toRemove) > 0 { fmt.Fprintln(options.Stdout, "Deleted Volumes:") - for _, name := range removedNames { - fmt.Fprintln(options.Stdout, name) - } + fmt.Fprintln(options.Stdout, strings.Join(toRemove, "\n")) fmt.Fprintln(options.Stdout, "") } + return nil } diff --git a/pkg/cmd/volume/rm.go b/pkg/cmd/volume/rm.go index 98a5a44c4bb..4b01564c009 100644 --- a/pkg/cmd/volume/rm.go +++ b/pkg/cmd/volume/rm.go @@ -19,55 +19,77 @@ package volume import ( "context" "encoding/json" + "errors" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/mountutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/mountutil" ) func Remove(ctx context.Context, client *containerd.Client, volumes []string, options types.VolumeRemoveOptions) error { - containers, err := client.Containers(ctx) - if err != nil { - return err - } volStore, err := Store(options.GOptions.Namespace, options.GOptions.DataRoot, options.GOptions.Address) if err != nil { return err } - usedVolumes, err := usedVolumes(ctx, containers) + + containers, err := client.Containers(ctx) if err != nil { return err } - var volumenames []string // nolint: prealloc - for _, name := range volumes { - volume, err := volStore.Get(name, false) + // Note: to avoid racy behavior, this is called by volStore.Remove *inside a lock* + removableVolumes := func() (volumeNames []string, cannotRemove []error, err error) { + usedVolumesList, err := usedVolumes(ctx, containers) if err != nil { - return err + return nil, nil, err } - if _, ok := usedVolumes[volume.Name]; ok { - return fmt.Errorf("volume %q is in use", name) + + for _, name := range volumes { + if _, ok := usedVolumesList[name]; ok { + cannotRemove = append(cannotRemove, fmt.Errorf("volume %q is in use (%w)", name, errdefs.ErrFailedPrecondition)) + continue + } + volumeNames = append(volumeNames, name) } - volumenames = append(volumenames, name) + + return volumeNames, cannotRemove, nil } - removedNames, err := volStore.Remove(volumenames) + + removedNames, cannotRemove, err := volStore.Remove(removableVolumes) if err != nil { return err } + // Otherwise, output on stdout whatever was successful for _, name := range removedNames { fmt.Fprintln(options.Stdout, name) } - return err + // Log the rest + for _, volErr := range cannotRemove { + log.G(ctx).Warn(volErr) + } + if len(cannotRemove) > 0 { + return errors.New("some volumes could not be removed") + } + return nil } func usedVolumes(ctx context.Context, containers []containerd.Container) (map[string]struct{}, error) { - usedVolumes := make(map[string]struct{}) + usedVolumesList := make(map[string]struct{}) for _, c := range containers { l, err := c.Labels(ctx) if err != nil { + // Containerd note: there is no guarantee that the containers we got from the list still exist at this point + // If that is the case, just ignore and move on + if errors.Is(err, errdefs.ErrNotFound) { + log.G(ctx).Debugf("container %q is gone - ignoring", c.ID()) + continue + } return nil, err } mountsJSON, ok := l[labels.Mounts] @@ -82,9 +104,9 @@ func usedVolumes(ctx context.Context, containers []containerd.Container) (map[st } for _, m := range mounts { if m.Type == mountutil.Volume { - usedVolumes[m.Name] = struct{}{} + usedVolumesList[m.Name] = struct{}{} } } } - return usedVolumes, nil + return usedVolumesList, nil } diff --git a/pkg/cmd/volume/volume.go b/pkg/cmd/volume/volume.go index 6efb1a7b46b..1a6fc9f16f1 100644 --- a/pkg/cmd/volume/volume.go +++ b/pkg/cmd/volume/volume.go @@ -17,8 +17,8 @@ package volume import ( - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" ) // Store returns a volume store diff --git a/pkg/composer/build.go b/pkg/composer/build.go index d389c358157..17b3fd0d8cd 100644 --- a/pkg/composer/build.go +++ b/pkg/composer/build.go @@ -21,10 +21,11 @@ import ( "fmt" "os" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" ) type BuildOptions struct { @@ -34,8 +35,8 @@ type BuildOptions struct { } func (c *Composer) Build(ctx context.Context, bo BuildOptions, services []string) error { - return c.project.WithServices(services, func(svc types.ServiceConfig) error { - ps, err := serviceparser.Parse(c.project, svc) + return c.project.ForEachService(services, func(names string, svc *types.ServiceConfig) error { + ps, err := serviceparser.Parse(c.project, *svc) if err != nil { return err } @@ -47,7 +48,7 @@ func (c *Composer) Build(ctx context.Context, bo BuildOptions, services []string } func (c *Composer) buildServiceImage(ctx context.Context, image string, b *serviceparser.Build, platform string, bo BuildOptions) error { - logrus.Infof("Building image %s", image) + log.G(ctx).Infof("Building image %s", image) var args []string // nolint: prealloc if platform != "" { @@ -66,7 +67,7 @@ func (c *Composer) buildServiceImage(ctx context.Context, image string, b *servi cmd := c.createNerdctlCmd(ctx, append([]string{"build"}, args...)...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index ef60632d96d..d159fbbbfaf 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -23,13 +23,15 @@ import ( "fmt" "os/exec" - composecli "github.com/compose-spec/compose-go/cli" - compose "github.com/compose-spec/compose-go/types" - "github.com/containerd/containerd" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/reflectutil" - "github.com/sirupsen/logrus" + composecli "github.com/compose-spec/compose-go/v2/cli" + compose "github.com/compose-spec/compose-go/v2/types" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) // Options groups the command line options recommended for a Compose implementation (ProjectOptions) and extra options for nerdctl @@ -61,8 +63,8 @@ func New(o Options, client *containerd.Client) (*Composer, error) { } if o.Project != "" { - if err := identifiers.Validate(o.Project); err != nil { - return nil, fmt.Errorf("got invalid project name %q: %w", o.Project, err) + if err := identifiers.ValidateDockerCompat(o.Project); err != nil { + return nil, fmt.Errorf("invalid project name: %w", err) } } @@ -70,9 +72,16 @@ func New(o Options, client *containerd.Client) (*Composer, error) { optionsFn = append(optionsFn, composecli.WithOsEnv, composecli.WithWorkingDirectory(o.ProjectDirectory), - composecli.WithEnvFile(o.EnvFile), + ) + if o.EnvFile != "" { + optionsFn = append(optionsFn, + composecli.WithEnvFiles(o.EnvFile), + ) + } + optionsFn = append(optionsFn, composecli.WithConfigFileEnv, composecli.WithDefaultConfigPath, + composecli.WithEnvFiles(), composecli.WithDotEnv, composecli.WithName(o.Project), ) @@ -81,7 +90,7 @@ func New(o Options, client *containerd.Client) (*Composer, error) { if err != nil { return nil, err } - project, err := composecli.ProjectFromOptions(projectOptions) + project, err := projectOptions.LoadProject(context.TODO()) if err != nil { return nil, err } @@ -93,12 +102,16 @@ func New(o Options, client *containerd.Client) (*Composer, error) { } o.Profiles = append(o.Profiles, s.GetProfiles()...) } - project.ApplyProfiles(o.Profiles) + + project, err = project.WithProfiles(o.Profiles) + if err != nil { + return nil, err + } if o.DebugPrintFull { projectJSON, _ := json.MarshalIndent(project, "", " ") - logrus.Debug("printing project JSON") - logrus.Debugf("%s", projectJSON) + log.L.Debug("printing project JSON") + log.L.Debugf("%s", projectJSON) } if unknown := reflectutil.UnknownNonEmptyFields(project, @@ -111,7 +124,7 @@ func New(o Options, client *containerd.Client) (*Composer, error) { "Secrets", "Configs", "ComposeFiles"); len(unknown) > 0 { - logrus.Warnf("Ignoring: %+v", unknown) + log.L.Warnf("Ignoring: %+v", unknown) } c := &Composer{ @@ -136,7 +149,7 @@ func (c *Composer) createNerdctlCmd(ctx context.Context, args ...string) *exec.C func (c *Composer) runNerdctlCmd(ctx context.Context, args ...string) error { cmd := c.createNerdctlCmd(ctx, args...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("error while executing %v: %q: %w", cmd.Args, string(out), err) @@ -147,8 +160,9 @@ func (c *Composer) runNerdctlCmd(ctx context.Context, args ...string) error { // Services returns the parsed Service objects in dependency order. func (c *Composer) Services(ctx context.Context, svcs ...string) ([]*serviceparser.Service, error) { var services []*serviceparser.Service - if err := c.project.WithServices(svcs, func(svc compose.ServiceConfig) error { - parsed, err := serviceparser.Parse(c.project, svc) + + if err := c.project.ForEachService(svcs, func(name string, svc *compose.ServiceConfig) error { + parsed, err := serviceparser.Parse(c.project, *svc) if err != nil { return err } @@ -163,7 +177,7 @@ func (c *Composer) Services(ctx context.Context, svcs ...string) ([]*servicepars // ServiceNames returns service names in dependency order. func (c *Composer) ServiceNames(svcs ...string) ([]string, error) { var names []string - if err := c.project.WithServices(svcs, func(svc compose.ServiceConfig) error { + if err := c.project.ForEachService(svcs, func(name string, svc *compose.ServiceConfig) error { names = append(names, svc.Name) return nil }); err != nil { diff --git a/pkg/composer/config.go b/pkg/composer/config.go index c58f67326ad..41a5320daf8 100644 --- a/pkg/composer/config.go +++ b/pkg/composer/config.go @@ -30,7 +30,7 @@ import ( "io" "strings" - "github.com/compose-spec/compose-go/types" + "github.com/compose-spec/compose-go/v2/types" "github.com/opencontainers/go-digest" "gopkg.in/yaml.v3" ) @@ -59,17 +59,14 @@ func (c *Composer) Config(ctx context.Context, w io.Writer, co ConfigOptions) er if co.Hash != "*" { services = strings.Split(co.Hash, ",") } - if err := c.project.WithServices(services, func(svc types.ServiceConfig) error { - hash, err := ServiceHash(svc) + return c.project.ForEachService(services, func(names string, svc *types.ServiceConfig) error { + hash, err := ServiceHash(*svc) if err != nil { return err } - fmt.Fprintf(w, "%s %s\n", svc.Name, hash) - return nil - }); err != nil { + _, err = fmt.Fprintf(w, "%s %s\n", svc.Name, hash) return err - } - return nil + }) } projectYAML, err := yaml.Marshal(c.project) if err != nil { @@ -84,7 +81,8 @@ func ServiceHash(o types.ServiceConfig) (string, error) { // remove the Build config when generating the service hash o.Build = nil o.PullPolicy = "" - o.Scale = 1 + o.Scale = new(int) + *(o.Scale) = 1 bytes, err := json.Marshal(o) if err != nil { return "", err diff --git a/pkg/composer/container.go b/pkg/composer/container.go index 3e5f94e9c6d..c789e42696a 100644 --- a/pkg/composer/container.go +++ b/pkg/composer/container.go @@ -20,9 +20,10 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/labels" ) func (c *Composer) Containers(ctx context.Context, services ...string) ([]containerd.Container, error) { @@ -34,7 +35,7 @@ func (c *Composer) Containers(ctx context.Context, services ...string) ([]contai if len(services) == 0 { filters = append(filters, projectLabel) } - logrus.Debugf("filters: %v", filters) + log.G(ctx).Debugf("filters: %v", filters) containers, err := c.client.Containers(ctx, filters...) if err != nil { return nil, err @@ -62,3 +63,24 @@ func (c *Composer) containerExists(ctx context.Context, name, service string) (b // container doesn't exist return false, nil } + +func (c *Composer) containerID(ctx context.Context, name, service string) (string, error) { + // get list of containers for service + containers, err := c.Containers(ctx, service) + if err != nil { + return "", err + } + + for _, container := range containers { + containerLabels, err := container.Labels(ctx) + if err != nil { + return "", err + } + if name == containerLabels[labels.Name] { + // container exists + return container.ID(), nil + } + } + // container doesn't exist + return "", nil +} diff --git a/pkg/composer/copy.go b/pkg/composer/copy.go new file mode 100644 index 00000000000..a75d56bac33 --- /dev/null +++ b/pkg/composer/copy.go @@ -0,0 +1,163 @@ +/* + 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 composer + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/docker/docker/pkg/system" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/labels" +) + +type CopyOptions struct { + Source string + Destination string + Index int + FollowLink bool + DryRun bool +} + +type copyDirection int + +const ( + fromService copyDirection = 0 + toService copyDirection = 1 +) + +func (c *Composer) Copy(ctx context.Context, co CopyOptions) error { + srcService, srcPath := splitCpArg(co.Source) + destService, dstPath := splitCpArg(co.Destination) + var serviceName string + var direction copyDirection + + if srcService != "" && destService != "" { + return errors.New("copying between services is not supported") + } + if srcService == "" && destService == "" { + return errors.New("unknown copy direction") + } + + if srcService != "" { + direction = fromService + serviceName = srcService + } + if destService != "" { + direction = toService + serviceName = destService + } + + containers, err := c.listContainersTargetedForCopy(ctx, co.Index, direction, serviceName) + if err != nil { + return err + } + + for _, container := range containers { + args := []string{"cp"} + if co.FollowLink { + args = append(args, "--follow-link") + } + if direction == fromService { + args = append(args, fmt.Sprintf("%s:%s", container.ID(), srcPath), dstPath) + } + if direction == toService { + args = append(args, srcPath, fmt.Sprintf("%s:%s", container.ID(), dstPath)) + } + err := c.logCopyMsg(ctx, container, direction, srcService, srcPath, destService, dstPath, co.DryRun) + if err != nil { + return err + } + if !co.DryRun { + if err := c.runNerdctlCmd(ctx, args...); err != nil { + return err + } + } + } + return nil +} + +func (c *Composer) logCopyMsg(ctx context.Context, container containerd.Container, direction copyDirection, srcService string, srcPath string, destService string, dstPath string, dryRun bool) error { + containerLabels, err := container.Labels(ctx) + if err != nil { + return err + } + containerName := containerLabels[labels.Name] + msg := "" + if dryRun { + msg = "DRY-RUN MODE - " + } + if direction == fromService { + msg = msg + fmt.Sprintf("copy %s:%s to %s", containerName, srcPath, dstPath) + } + if direction == toService { + msg = msg + fmt.Sprintf("copy %s to %s:%s", srcPath, containerName, dstPath) + } + log.G(ctx).Info(msg) + return nil +} + +func (c *Composer) listContainersTargetedForCopy(ctx context.Context, index int, direction copyDirection, serviceName string) ([]containerd.Container, error) { + var containers []containerd.Container + var err error + + containers, err = c.Containers(ctx, serviceName) + if err != nil { + return nil, err + } + + if index > 0 { + if index > len(containers) { + return nil, fmt.Errorf("index (%d) out of range: only %d running instances from service %s", + index, len(containers), serviceName) + } + container := containers[index-1] + return []containerd.Container{container}, nil + } + + if len(containers) < 1 { + return nil, fmt.Errorf("no container found for service %q", serviceName) + } + if direction == fromService { + return containers[:1], err + + } + return containers, err +} + +// https://github.com/docker/compose/blob/v2.21.0/pkg/compose/cp.go#L307 +func splitCpArg(arg string) (container, path string) { + if system.IsAbs(arg) { + // Explicit local absolute path, e.g., `C:\foo` or `/foo`. + return "", arg + } + + parts := strings.SplitN(arg, ":", 2) + + if len(parts) == 1 || strings.HasPrefix(parts[0], ".") { + // Either there's no `:` in the arg + // OR it's an explicit local relative path like `./file:name.txt`. + return "", arg + } + + return parts[0], parts[1] +} diff --git a/pkg/composer/create.go b/pkg/composer/create.go index a7aff1f5482..a2ae9180160 100644 --- a/pkg/composer/create.go +++ b/pkg/composer/create.go @@ -23,11 +23,13 @@ import ( "path/filepath" "strings" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/sirupsen/logrus" + "github.com/compose-spec/compose-go/v2/types" "golang.org/x/sync/errgroup" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" ) // FYI: https://github.com/docker/compose/blob/v2.14.1/pkg/api/api.go#L423 @@ -117,7 +119,7 @@ func (c *Composer) Create(ctx context.Context, opt CreateOptions, services []str return err } for _, ps := range parsedServices { - if err := c.ensureServiceImage(ctx, ps, !opt.NoBuild, opt.Build, BuildOptions{}, false); err != nil { + if err := c.ensureServiceImage(ctx, ps, !opt.NoBuild, opt.Build, BuildOptions{}, false, ""); err != nil { return err } } @@ -144,10 +146,7 @@ func (c *Composer) createService(ctx context.Context, ps *serviceparser.Service, return nil }) } - if err := runEG.Wait(); err != nil { - return err - } - return nil + return runEG.Wait() } // createServiceContainer must be called after ensureServiceImage @@ -166,18 +165,18 @@ func (c *Composer) createServiceContainer(ctx context.Context, service *servicep // delete container if it already exists and force-recreate is enabled if exists { if recreate != RecreateForce { - logrus.Infof("Container %s exists, skipping", container.Name) + log.G(ctx).Infof("Container %s exists, skipping", container.Name) return "", nil } - logrus.Debugf("Container %q already exists and force-created is enabled, deleting", container.Name) + log.G(ctx).Debugf("Container %q already exists and force-created is enabled, deleting", container.Name) delCmd := c.createNerdctlCmd(ctx, "rm", "-f", container.Name) if err = delCmd.Run(); err != nil { return "", fmt.Errorf("could not delete container %q: %s", container.Name, err) } - logrus.Infof("Re-creating container %s", container.Name) + log.G(ctx).Infof("Re-creating container %s", container.Name) } else { - logrus.Infof("Creating container %s", container.Name) + log.G(ctx).Infof("Creating container %s", container.Name) } tempDir, err := os.MkdirTemp(os.TempDir(), "compose-") @@ -196,7 +195,7 @@ func (c *Composer) createServiceContainer(ctx context.Context, service *servicep cmd := c.createNerdctlCmd(ctx, append([]string{"create"}, container.RunArgs...)...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } // FIXME diff --git a/pkg/composer/down.go b/pkg/composer/down.go index 9a7a7cc33b7..d02cf613082 100644 --- a/pkg/composer/down.go +++ b/pkg/composer/down.go @@ -20,9 +20,9 @@ import ( "context" "fmt" - "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) type DownOptions struct { @@ -41,6 +41,10 @@ func (c *Composer) Down(ctx context.Context, downOptions DownOptions) error { if err != nil { return err } + // use default Options to stop service containers. + if err := c.stopContainers(ctx, containers, StopOptions{}); err != nil { + return err + } if err := c.removeContainers(ctx, containers, RemoveOptions{Stop: true, Volumes: downOptions.RemoveVolumes}); err != nil { return err } @@ -61,7 +65,7 @@ func (c *Composer) Down(ctx context.Context, downOptions DownOptions) error { return fmt.Errorf("error removeing orphaned containers: %s", err) } } else { - logrus.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), orphans) } } @@ -87,7 +91,7 @@ func (c *Composer) downNetwork(ctx context.Context, shortName string) error { if !ok { return fmt.Errorf("invalid network name %q", shortName) } - if net.External.External { + if net.External { // NOP return nil } @@ -105,9 +109,9 @@ func (c *Composer) downNetwork(ctx context.Context, shortName string) error { return fmt.Errorf("network %s is in use", fullName) } - logrus.Infof("Removing network %s", fullName) + log.G(ctx).Infof("Removing network %s", fullName) if err := c.runNerdctlCmd(ctx, "network", "rm", fullName); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } } return nil @@ -118,19 +122,20 @@ func (c *Composer) downVolume(ctx context.Context, shortName string) error { if !ok { return fmt.Errorf("invalid volume name %q", shortName) } - if vol.External.External { + if vol.External { // NOP return nil } // shortName is like "db_data", fullName is like "compose-wordpress_db_data" fullName := vol.Name + // FIXME: this is racy. See note in up_volume.go volExists, err := c.VolumeExists(fullName) if err != nil { return err } else if volExists { - logrus.Infof("Removing volume %s", fullName) + log.G(ctx).Infof("Removing volume %s", fullName) if err := c.runNerdctlCmd(ctx, "volume", "rm", "-f", fullName); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } } return nil diff --git a/pkg/composer/exec.go b/pkg/composer/exec.go index 7b267365e18..04c6e077614 100644 --- a/pkg/composer/exec.go +++ b/pkg/composer/exec.go @@ -20,9 +20,15 @@ import ( "context" "fmt" "os" + "sort" + "strconv" + "strings" - "github.com/containerd/containerd" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" ) // ExecOptions stores options passed from users as flags and args. @@ -54,9 +60,21 @@ func (c *Composer) Exec(ctx context.Context, eo ExecOptions) error { return fmt.Errorf("index (%d) out of range: only %d running instances from service %s", eo.Index, len(containers), eo.ServiceName) } - container := containers[eo.Index-1] - - return c.exec(ctx, container, eo) + if len(containers) == 1 { + return c.exec(ctx, containers[0], eo) + } + // The order of the containers is not consistently ascending + // we need to re-sort them. + sort.SliceStable(containers, func(i, j int) bool { + infoI, _ := containers[i].Info(ctx, containerd.WithoutRefreshedMetadata) + infoJ, _ := containers[j].Info(ctx, containerd.WithoutRefreshedMetadata) + segsI := strings.Split(infoI.Labels[labels.Name], serviceparser.Separator) + segsJ := strings.Split(infoJ.Labels[labels.Name], serviceparser.Separator) + indexI, _ := strconv.Atoi(segsI[len(segsI)-1]) + indexJ, _ := strconv.Atoi(segsJ[len(segsJ)-1]) + return indexI < indexJ + }) + return c.exec(ctx, containers[eo.Index-1], eo) } // exec constructs/executes the `nerdctl exec` command to be executed on the given container. @@ -90,7 +108,7 @@ func (c *Composer) exec(ctx context.Context, container containerd.Container, eo } if c.DebugPrintFull { - logrus.Debugf("Executing %v", cmd.Args) + log.G(ctx).Debugf("Executing %v", cmd.Args) } return cmd.Run() } diff --git a/pkg/composer/kill.go b/pkg/composer/kill.go index fc63a751d4f..204ad30690a 100644 --- a/pkg/composer/kill.go +++ b/pkg/composer/kill.go @@ -19,8 +19,9 @@ package composer import ( "context" - "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" + + "github.com/containerd/log" ) type KillOptions struct { @@ -42,7 +43,7 @@ func (c *Composer) Kill(ctx context.Context, opts KillOptions, services []string eg.Go(func() error { args := []string{"kill", "-s", opts.Signal, container.ID()} if err := c.runNerdctlCmd(ctx, args...); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) return err } return nil diff --git a/pkg/composer/lock.go b/pkg/composer/lock.go new file mode 100644 index 00000000000..8fedda7bfc4 --- /dev/null +++ b/pkg/composer/lock.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 composer + +import ( + "os" + + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/lockutil" +) + +//nolint:unused +var locked *os.File + +func Lock(dataRoot string, address string) error { + // Compose right now cannot be made safe to use concurrently, as we shell out to nerdctl for multiple operations, + // preventing us from using the lock mechanisms from the API. + // This here allows to impose a global lock, effectively preventing multiple compose commands from being run in parallel and + // preventing some of the problems with concurrent execution. + // This should be removed once we have better, in-depth solutions to make compose concurrency safe. + // Note that in most cases we do not close the lock explicitly. Instead, the lock will get released when the `locked` global + // variable will get collected and the file descriptor closed (eg: when the binary exits). + var err error + dataStore, err := clientutil.DataStore(dataRoot, address) + if err != nil { + return err + } + locked, err = lockutil.Lock(dataStore) + return err +} + +func Unlock() error { + return lockutil.Unlock(locked) +} diff --git a/pkg/composer/logs.go b/pkg/composer/logs.go index 23e4ee94e7c..3d6f5cc2f65 100644 --- a/pkg/composer/logs.go +++ b/pkg/composer/logs.go @@ -18,31 +18,43 @@ package composer import ( "context" + "fmt" "os" "os/exec" "os/signal" "strings" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/composer/pipetagger" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/labels" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/pipetagger" + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" ) type LogsOptions struct { - Follow bool - Timestamps bool - Tail string - NoColor bool - NoLogPrefix bool + AbortOnContainerExit bool + Follow bool + Timestamps bool + Tail string + NoColor bool + NoLogPrefix bool + LatestRun bool } func (c *Composer) Logs(ctx context.Context, lo LogsOptions, services []string) error { + // Whether we called `compose logs`, or we are showing logs at the end of `up`, while in non detach mode, we need + // to release the lock. At this point, no operation will be performed that needs exclusive locking anymore, and + // not releasing the lock would otherwise unduly prevent further compose operations. + // See https://github.com/containerd/nerdctl/issues/3678 + if err := Unlock(); err != nil { + return err + } + var serviceNames []string - err := c.project.WithServices(services, func(svc types.ServiceConfig) error { + err := c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { serviceNames = append(serviceNames, svc.Name) return nil }, types.IgnoreDependencies) @@ -59,9 +71,10 @@ func (c *Composer) Logs(ctx context.Context, lo LogsOptions, services []string) func (c *Composer) logs(ctx context.Context, containers []containerd.Container, lo LogsOptions) error { var logTagMaxLen int type containerState struct { - name string - logTag string - logCmd *exec.Cmd + name string + logTag string + logCmd *exec.Cmd + startedAt string } containerStates := make(map[string]containerState, len(containers)) // key: containerID @@ -75,9 +88,15 @@ func (c *Composer) logs(ctx context.Context, containers []containerd.Container, if l := len(logTag); l > logTagMaxLen { logTagMaxLen = l } + ts, err := info.UpdatedAt.MarshalText() + if err != nil { + return err + } + containerStates[container.ID()] = containerState{ - name: name, - logTag: logTag, + name: name, + logTag: logTag, + startedAt: string(ts), } } @@ -99,6 +118,9 @@ func (c *Composer) logs(ctx context.Context, containers []containerd.Container, args = append(args, lo.Tail) } } + if lo.LatestRun { + args = append(args, fmt.Sprintf("--since=%s", state.startedAt)) + } args = append(args, id) state.logCmd = c.createNerdctlCmd(ctx, args...) @@ -117,7 +139,7 @@ func (c *Composer) logs(ctx context.Context, containers []containerd.Container, } stderrTagger := pipetagger.New(os.Stderr, stderr, state.logTag, logWidth, lo.NoColor) if c.DebugPrintFull { - logrus.Debugf("Running %v", state.logCmd.Args) + log.G(ctx).Debugf("Running %v", state.logCmd.Args) } if err := state.logCmd.Start(); err != nil { return err @@ -134,26 +156,33 @@ func (c *Composer) logs(ctx context.Context, containers []containerd.Container, signal.Notify(interruptChan, os.Interrupt) logsEOFMap := make(map[string]struct{}) // key: container name + var containerError error selectLoop: for { // Wait for Ctrl-C, or `nerdctl compose down` in another terminal select { case sig := <-interruptChan: - logrus.Debugf("Received signal: %s", sig) + log.G(ctx).Debugf("Received signal: %s", sig) break selectLoop case containerName := <-logsEOFChan: if lo.Follow { // When `nerdctl logs -f` has exited, we can assume that the container has exited - logrus.Infof("Container %q exited", containerName) + log.G(ctx).Infof("Container %q exited", containerName) + // In case a container has exited and the parameter --abort-on-container-exit, + // we break the loop and set an error, so we can exit the program with 1 + if lo.AbortOnContainerExit { + containerError = fmt.Errorf("container %q exited", containerName) + break selectLoop + } } else { - logrus.Debugf("Logs for container %q reached EOF", containerName) + log.G(ctx).Debugf("Logs for container %q reached EOF", containerName) } logsEOFMap[containerName] = struct{}{} if len(logsEOFMap) == len(containerStates) { if lo.Follow { - logrus.Info("All the containers have exited") + log.G(ctx).Info("All the containers have exited") } else { - logrus.Debug("All the logs reached EOF") + log.G(ctx).Debug("All the logs reached EOF") } break selectLoop } @@ -163,10 +192,10 @@ selectLoop: for _, state := range containerStates { if state.logCmd != nil && state.logCmd.Process != nil { if err := state.logCmd.Process.Kill(); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } } } - return nil + return containerError } diff --git a/pkg/composer/orphans.go b/pkg/composer/orphans.go index 2466d4ca174..eacac677fc3 100644 --- a/pkg/composer/orphans.go +++ b/pkg/composer/orphans.go @@ -20,9 +20,10 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/labels" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func (c *Composer) getOrphanContainers(ctx context.Context, parsedServices []*serviceparser.Service) ([]containerd.Container, error) { diff --git a/pkg/composer/pause.go b/pkg/composer/pause.go index 6f94cd60ffb..d0e7bc5aa77 100644 --- a/pkg/composer/pause.go +++ b/pkg/composer/pause.go @@ -22,10 +22,12 @@ import ( "io" "sync" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/containerutil" - "github.com/containerd/nerdctl/pkg/labels" "golang.org/x/sync/errgroup" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/labels" ) // Pause pauses service containers belonging to `services`. @@ -55,7 +57,7 @@ func (c *Composer) Pause(ctx context.Context, services []string, writer io.Write mu.Lock() defer mu.Unlock() - _, err = fmt.Fprintf(writer, "%s\n", info.Labels[labels.Name]) + _, err = fmt.Fprintln(writer, info.Labels[labels.Name]) return err }) @@ -91,7 +93,7 @@ func (c *Composer) Unpause(ctx context.Context, services []string, writer io.Wri mu.Lock() defer mu.Unlock() - _, err = fmt.Fprintf(writer, "%s\n", info.Labels[labels.Name]) + _, err = fmt.Fprintln(writer, info.Labels[labels.Name]) return err }) diff --git a/pkg/composer/pipetagger/pipetagger.go b/pkg/composer/pipetagger/pipetagger.go index b9f485b80cd..9b014354463 100644 --- a/pkg/composer/pipetagger/pipetagger.go +++ b/pkg/composer/pipetagger/pipetagger.go @@ -107,8 +107,5 @@ func (x *PipeTagger) Run() error { ) } } - if err := scanner.Err(); err != nil { - return err - } - return nil + return scanner.Err() } diff --git a/pkg/composer/port.go b/pkg/composer/port.go index 12676eb981f..750ee8ae970 100644 --- a/pkg/composer/port.go +++ b/pkg/composer/port.go @@ -21,7 +21,7 @@ import ( "fmt" "io" - "github.com/containerd/nerdctl/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" ) // PortOptions has args for getting the public port of a given private port/protocol diff --git a/pkg/composer/projectloader/projectloader.go b/pkg/composer/projectloader/projectloader.go deleted file mode 100644 index ef979c0d69b..00000000000 --- a/pkg/composer/projectloader/projectloader.go +++ /dev/null @@ -1,55 +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 projectloader - -import ( - "os" - "path/filepath" - - "github.com/compose-spec/compose-go/loader" - compose "github.com/compose-spec/compose-go/types" -) - -// Load is used only for unit testing. -// TODO: Remove -func Load(fileName, projectName string, envMap map[string]string) (*compose.Project, error) { - if envMap == nil { - envMap = make(map[string]string) - } - b, err := os.ReadFile(fileName) - if err != nil { - return nil, err - } - - wd, err := filepath.Abs(filepath.Dir(fileName)) - if err != nil { - return nil, err - } - var files []compose.ConfigFile - files = append(files, compose.ConfigFile{Filename: fileName, Content: b}) - return loader.Load(compose.ConfigDetails{ - WorkingDir: wd, - ConfigFiles: files, - Environment: envMap, - }, withProjectName(projectName)) -} - -func withProjectName(name string) func(*loader.Options) { - return func(lOpts *loader.Options) { - lOpts.SetProjectName(name, true) - } -} diff --git a/pkg/composer/pull.go b/pkg/composer/pull.go index 59bb50a3cc9..758d342f49e 100644 --- a/pkg/composer/pull.go +++ b/pkg/composer/pull.go @@ -21,10 +21,11 @@ import ( "fmt" "os" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" ) type PullOptions struct { @@ -32,8 +33,8 @@ type PullOptions struct { } func (c *Composer) Pull(ctx context.Context, po PullOptions, services []string) error { - return c.project.WithServices(services, func(svc types.ServiceConfig) error { - ps, err := serviceparser.Parse(c.project, svc) + return c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { + ps, err := serviceparser.Parse(c.project, *svc) if err != nil { return err } @@ -42,7 +43,7 @@ func (c *Composer) Pull(ctx context.Context, po PullOptions, services []string) } func (c *Composer) pullServiceImage(ctx context.Context, image string, platform string, ps *serviceparser.Service, po PullOptions) error { - logrus.Infof("Pulling image %s", image) + log.G(ctx).Infof("Pulling image %s", image) var args []string // nolint: prealloc if platform != "" { @@ -78,7 +79,7 @@ func (c *Composer) pullServiceImage(ctx context.Context, image string, platform cmd := c.createNerdctlCmd(ctx, append([]string{"pull"}, args...)...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/pkg/composer/push.go b/pkg/composer/push.go index ae8c6854e59..5f384601863 100644 --- a/pkg/composer/push.go +++ b/pkg/composer/push.go @@ -21,18 +21,19 @@ import ( "fmt" "os" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" ) type PushOptions struct { } func (c *Composer) Push(ctx context.Context, po PushOptions, services []string) error { - return c.project.WithServices(services, func(svc types.ServiceConfig) error { - ps, err := serviceparser.Parse(c.project, svc) + return c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { + ps, err := serviceparser.Parse(c.project, *svc) if err != nil { return err } @@ -41,7 +42,7 @@ func (c *Composer) Push(ctx context.Context, po PushOptions, services []string) } func (c *Composer) pushServiceImage(ctx context.Context, image string, platform string, ps *serviceparser.Service, po PushOptions) error { - logrus.Infof("Pushing image %s", image) + log.G(ctx).Infof("Pushing image %s", image) var args []string // nolint: prealloc if platform != "" { @@ -61,7 +62,7 @@ func (c *Composer) pushServiceImage(ctx context.Context, image string, platform cmd := c.createNerdctlCmd(ctx, append([]string{"push"}, args...)...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/pkg/composer/restart.go b/pkg/composer/restart.go index 1e3ff565b8a..e0ab0285b9a 100644 --- a/pkg/composer/restart.go +++ b/pkg/composer/restart.go @@ -21,11 +21,12 @@ import ( "fmt" "sync" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/labels" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/labels" ) // RestartOptions stores all option input from `nerdctl compose restart` @@ -37,16 +38,13 @@ type RestartOptions struct { // `nerdctl restart CONTAINER_ID` to do the actual job. func (c *Composer) Restart(ctx context.Context, opt RestartOptions, services []string) error { // in dependency order - return c.project.WithServices(services, func(svc types.ServiceConfig) error { + return c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { containers, err := c.Containers(ctx, svc.Name) if err != nil { return err } - if err := c.restartContainers(ctx, containers, opt); err != nil { - return err - } - return nil + return c.restartContainers(ctx, containers, opt) }) } @@ -64,14 +62,14 @@ func (c *Composer) restartContainers(ctx context.Context, containers []container go func() { defer rsWG.Done() info, _ := container.Info(ctx, containerd.WithoutRefreshedMetadata) - logrus.Infof("Restarting container %s", info.Labels[labels.Name]) + log.G(ctx).Infof("Restarting container %s", info.Labels[labels.Name]) args := []string{"restart"} if opt.Timeout != nil { args = append(args, timeoutArg) } args = append(args, container.ID()) if err := c.runNerdctlCmd(ctx, args...); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } }() } diff --git a/pkg/composer/rm.go b/pkg/composer/rm.go index 1eec295f466..c58cb024475 100644 --- a/pkg/composer/rm.go +++ b/pkg/composer/rm.go @@ -21,13 +21,13 @@ import ( "strings" "sync" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/strutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // RemoveOptions stores all options when removing compose containers: @@ -80,14 +80,14 @@ func (c *Composer) removeContainers(ctx context.Context, containers []containerd if !opt.Stop { cStatus := formatter.ContainerStatus(ctx, container) if strings.HasPrefix(cStatus, "Up") { - logrus.Warnf("Removing container %s failed: container still running.", info.Labels[labels.Name]) + log.G(ctx).Warnf("Removing container %s failed: container still running.", info.Labels[labels.Name]) return } } - logrus.Infof("Removing container %s", info.Labels[labels.Name]) + log.G(ctx).Infof("Removing container %s", info.Labels[labels.Name]) if err := c.runNerdctlCmd(ctx, append(args, container.ID())...); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } }() } @@ -104,9 +104,9 @@ func (c *Composer) removeContainersFromParsedServices(ctx context.Context, conta rmWG.Add(1) go func() { defer rmWG.Done() - logrus.Infof("Removing container %s", container.Name) + log.G(ctx).Infof("Removing container %s", container.Name) if err := c.runNerdctlCmd(ctx, "rm", "-f", id); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } }() } diff --git a/pkg/composer/run.go b/pkg/composer/run.go index 5a6e11f9f5d..0b3c4c72342 100644 --- a/pkg/composer/run.go +++ b/pkg/composer/run.go @@ -22,12 +22,14 @@ import ( "fmt" "sync" - "github.com/compose-spec/compose-go/loader" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/idgen" - "github.com/sirupsen/logrus" + "github.com/compose-spec/compose-go/v2/format" + "github.com/compose-spec/compose-go/v2/types" "golang.org/x/sync/errgroup" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/idgen" ) type RunOptions struct { @@ -45,6 +47,7 @@ type RunOptions struct { Detach bool NoDeps bool Tty bool + SigProxy bool Interactive bool Rm bool User string @@ -93,8 +96,8 @@ func (c *Composer) Run(ctx context.Context, ro RunOptions) error { } svcs = append(svcs, svc) } else { - if err := c.project.WithServices([]string{ro.ServiceName}, func(svc types.ServiceConfig) error { - svcs = append(svcs, svc) + if err := c.project.ForEachService([]string{ro.ServiceName}, func(name string, svc *types.ServiceConfig) error { + svcs = append(svcs, *svc) return nil }); err != nil { return err @@ -126,24 +129,24 @@ func (c *Composer) Run(ctx context.Context, ro RunOptions) error { if ro.User != "" { targetSvc.User = ro.User } - if ro.Volume != nil && len(ro.Volume) > 0 { + if len(ro.Volume) > 0 { for _, v := range ro.Volume { - vc, err := loader.ParseVolume(v) + vc, err := format.ParseVolume(v) if err != nil { return err } targetSvc.Volumes = append(targetSvc.Volumes, vc) } } - if ro.Entrypoint != nil && len(ro.Entrypoint) > 0 { + if len(ro.Entrypoint) > 0 { targetSvc.Entrypoint = make([]string, len(ro.Entrypoint)) copy(targetSvc.Entrypoint, ro.Entrypoint) } - if ro.Env != nil && len(ro.Env) > 0 { + if len(ro.Env) > 0 { envs := types.NewMappingWithEquals(ro.Env) targetSvc.Environment.OverrideBy(envs) } - if ro.Label != nil && len(ro.Label) > 0 { + if len(ro.Label) > 0 { label := types.NewMappingWithEquals(ro.Label) for k, v := range label { if v != nil { @@ -160,7 +163,7 @@ func (c *Composer) Run(ctx context.Context, ro RunOptions) error { for k := range svcs { svcs[k].Ports = []types.ServicePortConfig{} } - if ro.Publish != nil && len(ro.Publish) > 0 { + if len(ro.Publish) > 0 { for _, p := range ro.Publish { pc, err := types.ParsePortConfig(p) if err != nil { @@ -198,15 +201,11 @@ func (c *Composer) Run(ctx context.Context, ro RunOptions) error { return fmt.Errorf("error removing orphaned containers: %s", err) } } else { - logrus.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), orphans) } } - if err := c.runServices(ctx, parsedServices, ro); err != nil { - return err - } - - return nil + return c.runServices(ctx, parsedServices, ro) } func (c *Composer) runServices(ctx context.Context, parsedServices []*serviceparser.Service, ro RunOptions) error { @@ -216,7 +215,7 @@ func (c *Composer) runServices(ctx context.Context, parsedServices []*servicepar // TODO: parallelize loop for ensuring images (make sure not to mess up tty) for _, ps := range parsedServices { - if err := c.ensureServiceImage(ctx, ps, !ro.NoBuild, ro.ForceBuild, BuildOptions{}, ro.QuietPull); err != nil { + if err := c.ensureServiceImage(ctx, ps, !ro.NoBuild, ro.ForceBuild, BuildOptions{}, ro.QuietPull, ""); err != nil { return err } } @@ -234,7 +233,7 @@ func (c *Composer) runServices(ctx context.Context, parsedServices []*servicepar services = append(services, ps.Unparsed.Name) if len(ps.Containers) != 1 { - logrus.Warnf("compose run does not support scale but %s is currently %v, automatically it will configure 1", ps.Unparsed.Name, len(ps.Containers)) + log.G(ctx).Warnf("compose run does not support scale but %s is currently %v, automatically it will configure 1", ps.Unparsed.Name, len(ps.Containers)) } if len(ps.Containers) == 0 { @@ -243,7 +242,7 @@ func (c *Composer) runServices(ctx context.Context, parsedServices []*servicepar container := ps.Containers[0] runEG.Go(func() error { - id, err := c.upServiceContainer(ctx, ps, container) + id, err := c.upServiceContainer(ctx, ps, container, RecreateForce) if err != nil { return err } @@ -261,14 +260,14 @@ func (c *Composer) runServices(ctx context.Context, parsedServices []*servicepar } if ro.Detach { - logrus.Printf("%s\n", cid) + log.G(ctx).Printf("%s\n", cid) return nil } // TODO: fix it when `nerdctl logs` supports `nerdctl run` without detach // https://github.com/containerd/nerdctl/blob/v0.22.2/pkg/taskutil/taskutil.go#L55 if !ro.Interactive && !ro.Tty { - logrus.Info("Attaching to logs") + log.G(ctx).Info("Attaching to logs") lo := LogsOptions{ Follow: true, NoColor: ro.NoColor, @@ -280,7 +279,7 @@ func (c *Composer) runServices(ctx context.Context, parsedServices []*servicepar } } - logrus.Infof("Stopping containers (forcibly)") // TODO: support gracefully stopping + log.G(ctx).Infof("Stopping containers (forcibly)") // TODO: support gracefully stopping c.stopContainersFromParsedServices(ctx, containers) if ro.Rm { diff --git a/pkg/composer/serviceparser/build.go b/pkg/composer/serviceparser/build.go index a61e7975af8..c13b264301a 100644 --- a/pkg/composer/serviceparser/build.go +++ b/pkg/composer/serviceparser/build.go @@ -22,20 +22,21 @@ import ( "path/filepath" "strings" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/reflectutil" - + "github.com/compose-spec/compose-go/v2/types" securejoin "github.com/cyphar/filepath-securejoin" - "github.com/sirupsen/logrus" + + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName string) (*Build, error) { if unknown := reflectutil.UnknownNonEmptyFields(c, "Context", "Dockerfile", "Args", "CacheFrom", "Target", "Labels", "Secrets", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: build: %+v", unknown) + log.L.Warnf("Ignoring: build: %+v", unknown) } if c.Context == "" { @@ -44,16 +45,13 @@ func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName st if strings.Contains(c.Context, "://") { return nil, fmt.Errorf("build: URL-style context (%q) is not supported yet: %w", c.Context, errdefs.ErrNotImplemented) } - if filepath.IsAbs(c.Context) { - logrus.Warnf("build.config should be relative path, got %q", c.Context) - } ctxDir := project.RelativePath(c.Context) var b Build b.BuildArgs = append(b.BuildArgs, "-t="+imageName) if c.Dockerfile != "" { if filepath.IsAbs(c.Dockerfile) { - logrus.Warnf("build.dockerfile should be relative path, got %q", c.Dockerfile) + log.L.Warnf("build.dockerfile should be relative path, got %q", c.Dockerfile) b.BuildArgs = append(b.BuildArgs, "-f="+c.Dockerfile) } else { // no need to use securejoin @@ -86,16 +84,18 @@ func parseBuildConfig(c *types.BuildConfig, project *types.Project, imageName st for _, s := range c.Secrets { fileRef := types.FileReferenceConfig(s) - if err := identifiers.Validate(fileRef.Source); err != nil { - return nil, fmt.Errorf("secret source %q is invalid: %w", fileRef.Source, err) + + if err := identifiers.ValidateDockerCompat(fileRef.Source); err != nil { + return nil, fmt.Errorf("invalid secret source name: %w", err) } + projectSecret, ok := project.Secrets[fileRef.Source] if !ok { return nil, fmt.Errorf("build: secret %s is undefined", fileRef.Source) } var src string if filepath.IsAbs(projectSecret.File) { - logrus.Warnf("build.secrets should be relative path, got %q", projectSecret.File) + log.L.Warnf("build.secrets should be relative path, got %q", projectSecret.File) src = projectSecret.File } else { var err error diff --git a/pkg/composer/serviceparser/build_test.go b/pkg/composer/serviceparser/build_test.go index a789810a0d1..34af7143aec 100644 --- a/pkg/composer/serviceparser/build_test.go +++ b/pkg/composer/serviceparser/build_test.go @@ -17,11 +17,12 @@ package serviceparser import ( + "runtime" "testing" - "github.com/containerd/nerdctl/pkg/composer/projectloader" - "github.com/containerd/nerdctl/pkg/testutil" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func lastOf(ss []string) string { @@ -30,6 +31,11 @@ func lastOf(ss []string) string { func TestParseBuild(t *testing.T) { t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + const dockerComposeYAML = ` services: foo: @@ -59,7 +65,7 @@ secrets: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) fooSvc, err := project.GetService("foo") @@ -85,7 +91,7 @@ secrets: assert.Equal(t, project.RelativePath("barctx"), lastOf(bar.Build.BuildArgs)) assert.Assert(t, in(bar.Build.BuildArgs, "--target=bartgt")) assert.Assert(t, in(bar.Build.BuildArgs, "--label=bar=baz")) - secretPath := project.RelativePath("barctx") + secretPath := project.WorkingDir 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")) diff --git a/pkg/composer/serviceparser/serviceparser.go b/pkg/composer/serviceparser/serviceparser.go index 785fc97b9f8..2c45a1cecf8 100644 --- a/pkg/composer/serviceparser/serviceparser.go +++ b/pkg/composer/serviceparser/serviceparser.go @@ -28,12 +28,13 @@ import ( "strings" "time" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/containerd/contrib/nvidia" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/reflectutil" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/contrib/nvidia" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) // ComposeExtensionKey defines fields used to implement extension features. @@ -55,6 +56,7 @@ const Separator = "-" func warnUnknownFields(svc types.ServiceConfig) { if unknown := reflectutil.UnknownNonEmptyFields(&svc, "Name", + "Annotations", "Build", "BlkioConfig", "CapAdd", @@ -109,14 +111,14 @@ func warnUnknownFields(svc types.ServiceConfig) { "Volumes", "Ulimits", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: %+v", svc.Name, unknown) } if svc.BlkioConfig != nil { if unknown := reflectutil.UnknownNonEmptyFields(svc.BlkioConfig, "Weight", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: blkio_config: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: blkio_config: %+v", svc.Name, unknown) } } @@ -124,13 +126,13 @@ func warnUnknownFields(svc types.ServiceConfig) { if unknown := reflectutil.UnknownNonEmptyFields(&dep, "Condition", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: depends_on: %s: %+v", svc.Name, depName, unknown) + log.L.Warnf("Ignoring: service %s: depends_on: %s: %+v", svc.Name, depName, unknown) } switch dep.Condition { case "", types.ServiceConditionStarted: // NOP default: - logrus.Warnf("Ignoring: service %s: depends_on: %s: condition %s", svc.Name, depName, dep.Condition) + log.L.Warnf("Ignoring: service %s: depends_on: %s: condition %s", svc.Name, depName, dep.Condition) } } @@ -140,34 +142,34 @@ func warnUnknownFields(svc types.ServiceConfig) { "RestartPolicy", "Resources", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: deploy: %+v", svc.Name, unknown) } if svc.Deploy.RestartPolicy != nil { if unknown := reflectutil.UnknownNonEmptyFields(svc.Deploy.RestartPolicy, "Condition", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy.restart_policy: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: deploy.restart_policy: %+v", svc.Name, unknown) } } if unknown := reflectutil.UnknownNonEmptyFields(svc.Deploy.Resources, "Limits", "Reservations", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy.resources: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: deploy.resources: %+v", svc.Name, unknown) } if svc.Deploy.Resources.Limits != nil { if unknown := reflectutil.UnknownNonEmptyFields(svc.Deploy.Resources.Limits, "NanoCPUs", "MemoryBytes", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy.resources.resources: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: deploy.resources.resources: %+v", svc.Name, unknown) } } if svc.Deploy.Resources.Reservations != nil { if unknown := reflectutil.UnknownNonEmptyFields(svc.Deploy.Resources.Reservations, "Devices", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy.resources.resources.reservations: %+v", svc.Name, unknown) + log.L.Warnf("Ignoring: service %s: deploy.resources.resources.reservations: %+v", svc.Name, unknown) } for i, dev := range svc.Deploy.Resources.Reservations.Devices { if unknown := reflectutil.UnknownNonEmptyFields(dev, @@ -176,7 +178,7 @@ func warnUnknownFields(svc types.ServiceConfig) { "Count", "IDs", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: service %s: deploy.resources.resources.reservations.devices[%d]: %+v", + log.L.Warnf("Ignoring: service %s: deploy.resources.resources.reservations.devices[%d]: %+v", svc.Name, i, unknown) } } @@ -213,10 +215,10 @@ func getReplicas(svc types.ServiceConfig) (int, error) { // https://github.com/compose-spec/compose-go/commit/958cb4f953330a3d1303961796d826b7f79132d7 if svc.Deploy != nil && svc.Deploy.Replicas != nil { - replicas = int(*svc.Deploy.Replicas) + replicas = int(*svc.Deploy.Replicas) // nolint:unconvert } - if replicas < 1 { + if replicas < 0 { return 0, fmt.Errorf("invalid replicas: %d", replicas) } return replicas, nil @@ -225,15 +227,15 @@ func getReplicas(svc types.ServiceConfig) (int, error) { func getCPULimit(svc types.ServiceConfig) (string, error) { var limit string if svc.CPUS > 0 { - logrus.Warn("cpus is deprecated, use deploy.resources.limits.cpus") + log.L.Warn("cpus is deprecated, use deploy.resources.limits.cpus") limit = fmt.Sprintf("%f", svc.CPUS) } if svc.Deploy != nil && svc.Deploy.Resources.Limits != nil { - if nanoCPUs := svc.Deploy.Resources.Limits.NanoCPUs; nanoCPUs != "" { + if nanoCPUs := svc.Deploy.Resources.Limits.NanoCPUs; nanoCPUs != 0 { if svc.CPUS > 0 { - logrus.Warnf("deploy.resources.limits.cpus and cpus (deprecated) must not be set together, ignoring cpus=%f", svc.CPUS) + log.L.Warnf("deploy.resources.limits.cpus and cpus (deprecated) must not be set together, ignoring cpus=%f", svc.CPUS) } - limit = nanoCPUs + limit = strconv.FormatFloat(float64(nanoCPUs), 'f', 2, 32) } } return limit, nil @@ -242,13 +244,13 @@ func getCPULimit(svc types.ServiceConfig) (string, error) { func getMemLimit(svc types.ServiceConfig) (types.UnitBytes, error) { var limit types.UnitBytes if svc.MemLimit > 0 { - logrus.Warn("mem_limit is deprecated, use deploy.resources.limits.memory") + log.L.Warn("mem_limit is deprecated, use deploy.resources.limits.memory") limit = svc.MemLimit } if svc.Deploy != nil && svc.Deploy.Resources.Limits != nil { if memoryBytes := svc.Deploy.Resources.Limits.MemoryBytes; memoryBytes > 0 { if svc.MemLimit > 0 && memoryBytes != svc.MemLimit { - logrus.Warnf("deploy.resources.limits.memory and mem_limit (deprecated) must not be set together, ignoring mem_limit=%d", svc.MemLimit) + log.L.Warnf("deploy.resources.limits.memory and mem_limit (deprecated) must not be set together, ignoring mem_limit=%d", svc.MemLimit) } limit = memoryBytes } @@ -327,13 +329,13 @@ func getRestart(svc types.ServiceConfig) (string, error) { if restartFailurePat.MatchString(svc.Restart) { restartFlag = svc.Restart } else { - logrus.Warnf("Ignoring: service %s: restart=%q (unknown)", svc.Name, svc.Restart) + log.L.Warnf("Ignoring: service %s: restart=%q (unknown)", svc.Name, svc.Restart) } } if svc.Deploy != nil && svc.Deploy.RestartPolicy != nil { if svc.Restart != "" { - logrus.Warnf("deploy.restart_policy and restart must not be set together, ignoring restart=%s", svc.Restart) + log.L.Warnf("deploy.restart_policy and restart must not be set together, ignoring restart=%s", svc.Restart) } switch cond := svc.Deploy.RestartPolicy.Condition; cond { case "", "any": @@ -345,9 +347,9 @@ func getRestart(svc types.ServiceConfig) (string, error) { case "no": return "", fmt.Errorf("deploy.restart_policy.condition: \"no\" is invalid, did you mean \"none\"?") case "on-failure": - logrus.Warnf("Ignoring: service %s: deploy.restart_policy.condition=%q (unimplemented)", svc.Name, cond) + log.L.Warnf("Ignoring: service %s: deploy.restart_policy.condition=%q (unimplemented)", svc.Name, cond) default: - logrus.Warnf("Ignoring: service %s: deploy.restart_policy.condition=%q (unknown)", svc.Name, cond) + log.L.Warnf("Ignoring: service %s: deploy.restart_policy.condition=%q (unknown)", svc.Name, cond) } } @@ -364,7 +366,7 @@ func getNetworks(project *types.Project, svc types.ServiceConfig) ([]networkName var fullNames []networkNamePair // nolint: prealloc if svc.Net != "" { - logrus.Warn("net is deprecated, use network_mode or networks") + log.L.Warn("net is deprecated, use network_mode or networks") if len(svc.Networks) > 0 { return nil, errors.New("networks and net must not be set together") } @@ -383,7 +385,7 @@ func getNetworks(project *types.Project, svc types.ServiceConfig) ([]networkName return nil, errors.New("net and network_mode must not be set together") } if strings.Contains(svc.NetworkMode, ":") { - if !strings.HasPrefix(svc.NetworkMode, "container:") { + if !strings.HasPrefix(svc.NetworkMode, "container:") && !strings.HasPrefix(svc.NetworkMode, "ns:") { return nil, fmt.Errorf("unsupported network_mode: %q", svc.NetworkMode) } } @@ -448,7 +450,7 @@ func Parse(project *types.Project, svc types.ServiceConfig) (*Service, error) { parsed.Build.Force = true parsed.PullMode = "never" default: - logrus.Warnf("Ignoring: service %s: pull_policy: %q", svc.Name, svc.PullPolicy) + log.L.Warnf("Ignoring: service %s: pull_policy: %q", svc.Name, svc.PullPolicy) } for i := 0; i < replicas; i++ { @@ -478,6 +480,14 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e "--pull=never", // because image will be ensured before running replicas with `nerdctl run`. } + for k, v := range svc.Annotations { + if v == "" { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--annotation=%s", k)) + } else { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--annotation=%s=%s", k, v)) + } + } + if svc.BlkioConfig != nil && svc.BlkioConfig.Weight != 0 { c.RunArgs = append(c.RunArgs, fmt.Sprintf("--blkio-weight=%d", svc.BlkioConfig.Weight)) } @@ -505,7 +515,7 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e } for _, v := range svc.Devices { - c.RunArgs = append(c.RunArgs, fmt.Sprintf("--device=%s", v)) + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--device=%s:%s:%s", v.Source, v.Target, v.Permissions)) } for _, v := range svc.DNS { @@ -530,7 +540,9 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e } } for k, v := range svc.ExtraHosts { - c.RunArgs = append(c.RunArgs, fmt.Sprintf("--add-host=%s:%s", k, v)) + for _, h := range v { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--add-host=%s:%s", k, h)) + } } if svc.Init != nil && *svc.Init { @@ -584,6 +596,9 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e if value != nil && value.Ipv4Address != "" { c.RunArgs = append(c.RunArgs, "--ip="+value.Ipv4Address) } + if value != nil && value.MacAddress != "" { + c.RunArgs = append(c.RunArgs, "--mac-address="+value.MacAddress) + } } } @@ -674,6 +689,10 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e c.RunArgs = append(c.RunArgs, "--user="+svc.User) } + for _, v := range svc.GroupAdd { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--group-add=%s", v)) + } + for _, v := range svc.Volumes { vStr, mkdir, err := serviceVolumeConfigToFlagV(v, project) if err != nil { @@ -726,7 +745,7 @@ func servicePortConfigToFlagP(c types.ServicePortConfig) (string, error) { "Published", "Protocol", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: port: %+v", unknown) + log.L.Warnf("Ignoring: port: %+v", unknown) } switch c.Mode { case "", "ingress": @@ -759,18 +778,18 @@ func serviceVolumeConfigToFlagV(c types.ServiceVolumeConfig, project *types.Proj "Bind", "Volume", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: volume: %+v", unknown) + log.L.Warnf("Ignoring: volume: %+v", unknown) } if c.Bind != nil { // c.Bind is expected to be a non-nil reference to an empty Bind struct if unknown := reflectutil.UnknownNonEmptyFields(c.Bind, "CreateHostPath"); len(unknown) > 0 { - logrus.Warnf("Ignoring: volume: Bind: %+v", unknown) + log.L.Warnf("Ignoring: volume: Bind: %+v", unknown) } } if c.Volume != nil { // c.Volume is expected to be a non-nil reference to an empty Volume struct if unknown := reflectutil.UnknownNonEmptyFields(c.Volume); len(unknown) > 0 { - logrus.Warnf("Ignoring: volume: Volume: %+v", unknown) + log.L.Warnf("Ignoring: volume: Volume: %+v", unknown) } } @@ -829,11 +848,11 @@ func fileReferenceConfigToFlagV(c types.FileReferenceConfig, project *types.Proj if unknown := reflectutil.UnknownNonEmptyFields(&c, "Source", "Target", "UID", "GID", "Mode", ); len(unknown) > 0 { - logrus.Warnf("Ignoring: %s: %+v", objType, unknown) + log.L.Warnf("Ignoring: %s: %+v", objType, unknown) } - if err := identifiers.Validate(c.Source); err != nil { - return "", fmt.Errorf("%s source %q is invalid: %w", objType, c.Source, err) + if err := identifiers.ValidateDockerCompat(c.Source); err != nil { + return "", fmt.Errorf("invalid source name for %s: %w", objType, err) } var obj types.FileObjectConfig diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 308e7cb76e4..c3eaee0cd49 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -20,14 +20,15 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strconv" "testing" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/projectloader" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/containerd/nerdctl/pkg/testutil" + "github.com/compose-spec/compose-go/v2/types" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) func TestServicePortConfigToFlagP(t *testing.T) { @@ -79,6 +80,11 @@ var in = strutil.InStringSlice func TestParse(t *testing.T) { t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + const dockerComposeYAML = ` version: '3.1' @@ -117,6 +123,9 @@ services: options: max-size: "5K" max-file: "2" + user: 1001:1001 + group_add: + - "1001" db: image: mariadb:10.5 @@ -138,7 +147,7 @@ volumes: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) wpSvc, err := project.GetService("wordpress") @@ -174,6 +183,8 @@ volumes: assert.Assert(t, in(wp1.RunArgs, "--add-host=test.com:172.19.1.1")) assert.Assert(t, in(wp1.RunArgs, "--add-host=test2.com:172.19.1.2")) assert.Assert(t, in(wp1.RunArgs, "--shm-size=1073741824")) + assert.Assert(t, in(wp1.RunArgs, "--user=1001:1001")) + assert.Assert(t, in(wp1.RunArgs, "--group-add=1001")) dbSvc, err := project.GetService("db") assert.NilError(t, err) @@ -197,7 +208,7 @@ func TestParseDeprecated(t *testing.T) { services: foo: image: nginx:alpine - # scale is deprecated in favor of deploy.replicas, but still valid + # scale was deprecated in favor of deploy.replicas, and is now ignored scale: 2 # cpus is deprecated in favor of deploy.resources.limits.cpu, but still valid cpus: 0.42 @@ -207,7 +218,7 @@ services: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) fooSvc, err := project.GetService("foo") @@ -217,7 +228,7 @@ services: assert.NilError(t, err) t.Logf("foo: %+v", foo) - assert.Assert(t, len(foo.Containers) == 2) + assert.Assert(t, len(foo.Containers) == 1) for i, c := range foo.Containers { assert.Assert(t, c.Name == DefaultContainerName(project.Name, "foo", strconv.Itoa(i+1))) assert.Assert(t, in(c.RunArgs, "--name="+c.Name)) @@ -260,11 +271,15 @@ services: devices: - capabilities: ["utility"] count: all + qux: # replicas=0 + image: nginx:alpine + deploy: + replicas: 0 ` comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) fooSvc, err := project.GetService("foo") @@ -310,10 +325,54 @@ services: assert.Assert(t, in(c.RunArgs, "--restart=no")) assert.Assert(t, in(c.RunArgs, `--gpus=capabilities=utility,count=-1`)) } + + quxSvc, err := project.GetService("qux") + assert.NilError(t, err) + + qux, err := Parse(project, quxSvc) + assert.NilError(t, err) + + t.Logf("qux: %+v", qux) + assert.Assert(t, len(qux.Containers) == 0) + +} + +func TestParseDevices(t *testing.T) { + const dockerComposeYAML = ` +services: + foo: + image: nginx:alpine + devices: + - /dev/a + - /dev/b:/dev/b + - /dev/c:/dev/c:rw +` + 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, "--device=/dev/a:/dev/a:rwm")) + assert.Assert(t, in(c.RunArgs, "--device=/dev/b:/dev/b:rwm")) + assert.Assert(t, in(c.RunArgs, "--device=/dev/c:/dev/c:rw")) + } } func TestParseRelative(t *testing.T) { t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } const dockerComposeYAML = ` services: foo: @@ -327,7 +386,7 @@ services: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) fooSvc, err := project.GetService("foo") @@ -359,7 +418,7 @@ services: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) fooSvc, err := project.GetService("foo") @@ -389,6 +448,9 @@ services: func TestParseConfigs(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } const dockerComposeYAML = ` services: foo: @@ -419,7 +481,7 @@ configs: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) for _, f := range []string{"secret1", "secret2", "secret3", "config1", "config2"} { @@ -463,7 +525,7 @@ services: comp := testutil.NewComposeDir(t, dockerComposeYAML) defer comp.CleanUp() - project, err := projectloader.Load(comp.YAMLFullPath(), comp.ProjectName(), nil) + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) getContainersFromService := func(svcName string) []Container { diff --git a/pkg/composer/stop.go b/pkg/composer/stop.go index 863563b1d23..7f514cedaff 100644 --- a/pkg/composer/stop.go +++ b/pkg/composer/stop.go @@ -21,12 +21,12 @@ import ( "fmt" "sync" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/strutil" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // StopOptions stores all option input from `nerdctl compose stop` @@ -68,14 +68,14 @@ func (c *Composer) stopContainers(ctx context.Context, containers []containerd.C go func() { defer rmWG.Done() info, _ := container.Info(ctx, containerd.WithoutRefreshedMetadata) - logrus.Infof("Stopping container %s", info.Labels[labels.Name]) + log.G(ctx).Infof("Stopping container %s", info.Labels[labels.Name]) args := []string{"stop"} if opt.Timeout != nil { args = append(args, timeoutArg) } args = append(args, container.ID()) if err := c.runNerdctlCmd(ctx, args...); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } }() } @@ -92,9 +92,9 @@ func (c *Composer) stopContainersFromParsedServices(ctx context.Context, contain rmWG.Add(1) go func() { defer rmWG.Done() - logrus.Infof("Stopping container %s", container.Name) + log.G(ctx).Infof("Stopping container %s", container.Name) if err := c.runNerdctlCmd(ctx, "stop", id); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } }() } diff --git a/pkg/composer/up.go b/pkg/composer/up.go index 40eb752d0a1..98106c81ae4 100644 --- a/pkg/composer/up.go +++ b/pkg/composer/up.go @@ -21,23 +21,39 @@ import ( "fmt" "os" - "github.com/compose-spec/compose-go/types" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/reflectutil" + "github.com/compose-spec/compose-go/v2/types" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) type UpOptions struct { - Detach bool - NoBuild bool - NoColor bool - NoLogPrefix bool - ForceBuild bool - IPFS bool - QuietPull bool - RemoveOrphans bool - Scale map[string]uint64 // map of service name to replicas + AbortOnContainerExit bool + Detach bool + NoBuild bool + NoColor bool + NoLogPrefix bool + ForceBuild bool + IPFS bool + QuietPull bool + RemoveOrphans bool + ForceRecreate bool + NoRecreate bool + Scale map[string]int // map of service name to replicas + Pull string +} + +func (opts UpOptions) recreateStrategy() string { + switch { + case opts.ForceRecreate: + return RecreateForce + case opts.NoRecreate: + return RecreateNever + default: + return RecreateDiverged + } } func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) error { @@ -69,14 +85,14 @@ 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.WithServices(services, func(svc types.ServiceConfig) error { + if err := c.project.ForEachService(services, func(name string, svc *types.ServiceConfig) error { if replicas, ok := uo.Scale[svc.Name]; ok { if svc.Deploy == nil { svc.Deploy = &types.DeployConfig{} } svc.Deploy.Replicas = &replicas } - ps, err := serviceparser.Parse(c.project, svc) + ps, err := serviceparser.Parse(c.project, *svc) if err != nil { return err } @@ -98,24 +114,18 @@ func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) erro return fmt.Errorf("error removing orphaned containers: %s", err) } } else { - logrus.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), orphans) } } - if err := c.upServices(ctx, parsedServices, uo); err != nil { - return err - } - - return nil + return c.upServices(ctx, parsedServices, uo) } func validateFileObjectConfig(obj types.FileObjectConfig, shortName, objType string, project *types.Project) error { if unknown := reflectutil.UnknownNonEmptyFields(&obj, "Name", "External", "File"); len(unknown) > 0 { - logrus.Warnf("Ignoring: %s %s: %+v", objType, shortName, unknown) - } - if obj.External.External || obj.External.Name != "" { - return fmt.Errorf("%s %q: external object is not supported", objType, shortName) + log.L.Warnf("Ignoring: %s %s: %+v", objType, shortName, unknown) } + if obj.File == "" { return fmt.Errorf("%s %q: lacks file path", objType, shortName) } diff --git a/pkg/composer/up_network.go b/pkg/composer/up_network.go index c9c76e6b7c4..a68b63c0f71 100644 --- a/pkg/composer/up_network.go +++ b/pkg/composer/up_network.go @@ -20,10 +20,10 @@ import ( "context" "fmt" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/reflectutil" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) func (c *Composer) upNetwork(ctx context.Context, shortName string) error { @@ -31,13 +31,13 @@ func (c *Composer) upNetwork(ctx context.Context, shortName string) error { if !ok { return fmt.Errorf("invalid network name %q", shortName) } - if net.External.External { + if net.External { // NOP return nil } if unknown := reflectutil.UnknownNonEmptyFields(&net, "Name", "Ipam", "Driver", "DriverOpts"); len(unknown) > 0 { - logrus.Warnf("Ignoring: network %s: %+v", shortName, unknown) + log.G(ctx).Warnf("Ignoring: network %s: %+v", shortName, unknown) } // shortName is like "default", fullName is like "compose-wordpress_default" @@ -46,7 +46,7 @@ func (c *Composer) upNetwork(ctx context.Context, shortName string) error { if err != nil { return err } else if !netExists { - logrus.Infof("Creating network %s", fullName) + log.G(ctx).Infof("Creating network %s", fullName) //add metadata labels to network https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels-1 createArgs := []string{ fmt.Sprintf("--label=%s=%s", labels.ComposeProject, c.project.Name), @@ -65,12 +65,12 @@ func (c *Composer) upNetwork(ctx context.Context, shortName string) error { if net.Ipam.Config != nil { if len(net.Ipam.Config) > 1 { - logrus.Warnf("Ignoring: network %s: imam.config %+v", shortName, net.Ipam.Config[1:]) + log.G(ctx).Warnf("Ignoring: network %s: imam.config %+v", shortName, net.Ipam.Config[1:]) } ipamConfig := net.Ipam.Config[0] if unknown := reflectutil.UnknownNonEmptyFields(ipamConfig, "Subnet", "Gateway", "IPRange"); len(unknown) > 0 { - logrus.Warnf("Ignoring: network %s: ipam.config[0]: %+v", shortName, unknown) + log.G(ctx).Warnf("Ignoring: network %s: ipam.config[0]: %+v", shortName, unknown) } if ipamConfig.Subnet != "" { createArgs = append(createArgs, fmt.Sprintf("--subnet=%s", ipamConfig.Subnet)) @@ -86,7 +86,7 @@ func (c *Composer) upNetwork(ctx context.Context, shortName string) error { createArgs = append(createArgs, fullName) if c.DebugPrintFull { - logrus.Debugf("Creating network args: %s", createArgs) + log.G(ctx).Debugf("Creating network args: %s", createArgs) } if err := c.runNerdctlCmd(ctx, append([]string{"network", "create"}, createArgs...)...); err != nil { diff --git a/pkg/composer/up_service.go b/pkg/composer/up_service.go index 5aa5d3417f0..f0da7c9b72a 100644 --- a/pkg/composer/up_service.go +++ b/pkg/composer/up_service.go @@ -21,15 +21,17 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "sync" - "github.com/containerd/nerdctl/pkg/composer/serviceparser" - "github.com/containerd/nerdctl/pkg/labels" - - "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/v2/pkg/labels" ) func (c *Composer) upServices(ctx context.Context, parsedServices []*serviceparser.Service, uo UpOptions) error { @@ -39,11 +41,13 @@ func (c *Composer) upServices(ctx context.Context, parsedServices []*servicepars // TODO: parallelize loop for ensuring images (make sure not to mess up tty) for _, ps := range parsedServices { - if err := c.ensureServiceImage(ctx, ps, !uo.NoBuild, uo.ForceBuild, BuildOptions{}, uo.QuietPull); err != nil { + if err := c.ensureServiceImage(ctx, ps, !uo.NoBuild, uo.ForceBuild, BuildOptions{}, uo.QuietPull, uo.Pull); err != nil { return err } } + recreate := uo.recreateStrategy() + var ( containers = make(map[string]serviceparser.Container) // key: container ID services = []string{} @@ -56,7 +60,7 @@ func (c *Composer) upServices(ctx context.Context, parsedServices []*servicepars for _, container := range ps.Containers { container := container runEG.Go(func() error { - id, err := c.upServiceContainer(ctx, ps, container) + id, err := c.upServiceContainer(ctx, ps, container, recreate) if err != nil { return err } @@ -75,22 +79,29 @@ func (c *Composer) upServices(ctx context.Context, parsedServices []*servicepars return nil } - logrus.Info("Attaching to logs") + // this is used to stop containers in case --abort-on-container-exit flag is set. + // c.Logs returns an error, so we don't need Ctrl-c to reach the "Stopping containers (forcibly)" + if uo.AbortOnContainerExit { + defer c.stopContainersFromParsedServices(ctx, containers) + } + log.G(ctx).Info("Attaching to logs") lo := LogsOptions{ - Follow: true, - NoColor: uo.NoColor, - NoLogPrefix: uo.NoLogPrefix, + AbortOnContainerExit: uo.AbortOnContainerExit, + Follow: true, + NoColor: uo.NoColor, + NoLogPrefix: uo.NoLogPrefix, + LatestRun: recreate == RecreateNever, } if err := c.Logs(ctx, lo, services); err != nil { return err } - logrus.Infof("Stopping containers (forcibly)") // TODO: support gracefully stopping + log.G(ctx).Infof("Stopping containers (forcibly)") // TODO: support gracefully stopping c.stopContainersFromParsedServices(ctx, containers) return nil } -func (c *Composer) ensureServiceImage(ctx context.Context, ps *serviceparser.Service, allowBuild, forceBuild bool, bo BuildOptions, quiet bool) error { +func (c *Composer) ensureServiceImage(ctx context.Context, ps *serviceparser.Service, allowBuild, forceBuild bool, bo BuildOptions, quiet bool, pullModeArg string) error { if ps.Build != nil && allowBuild { if ps.Build.Force || forceBuild { return c.buildServiceImage(ctx, ps.Image, ps.Build, ps.Unparsed.Platform, bo) @@ -102,39 +113,59 @@ func (c *Composer) ensureServiceImage(ctx context.Context, ps *serviceparser.Ser } // even when c.ImageExists returns true, we need to call c.EnsureImage // because ps.PullMode can be "always". So no return here. - logrus.Debugf("Image %s already exists, not building", ps.Image) + log.G(ctx).Debugf("Image %s already exists, not building", ps.Image) } - logrus.Infof("Ensuring image %s", ps.Image) - if err := c.EnsureImage(ctx, ps.Image, ps.PullMode, ps.Unparsed.Platform, ps, quiet); err != nil { - return err + log.G(ctx).Infof("Ensuring image %s", ps.Image) + if pullModeArg != "" { + return c.EnsureImage(ctx, ps.Image, pullModeArg, ps.Unparsed.Platform, ps, quiet) } - return nil + return c.EnsureImage(ctx, ps.Image, ps.PullMode, ps.Unparsed.Platform, ps, quiet) } // upServiceContainer must be called after ensureServiceImage // upServiceContainer returns container ID -func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparser.Service, container serviceparser.Container) (string, error) { +func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparser.Service, container serviceparser.Container, recreate string) (string, error) { // check if container already exists - exists, err := c.containerExists(ctx, container.Name, service.Unparsed.Name) + existingCid, err := c.containerID(ctx, container.Name, service.Unparsed.Name) if err != nil { return "", fmt.Errorf("error while checking for containers with name %q: %s", container.Name, err) } + // FIXME + if service.Unparsed.StdinOpen != service.Unparsed.Tty { + return "", fmt.Errorf("currently StdinOpen(-i) and Tty(-t) should be same") + } + + var runFlagD bool + if !service.Unparsed.StdinOpen && !service.Unparsed.Tty { + container.RunArgs = append([]string{"-d"}, container.RunArgs...) + runFlagD = true + } + + // start the existing container and exit early + if existingCid != "" && recreate == RecreateNever { + 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 + } + // delete container if it already exists - if exists { - logrus.Debugf("Container %q already exists, deleting", container.Name) + if existingCid != "" { + 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 { return "", fmt.Errorf("could not delete container %q: %s", container.Name, err) } - logrus.Infof("Re-creating container %s", container.Name) + log.G(ctx).Infof("Re-creating container %s", container.Name) } else { - logrus.Infof("Creating container %s", container.Name) + log.G(ctx).Infof("Creating container %s", container.Name) } for _, f := range container.Mkdir { - logrus.Debugf("Creating a directory %q", f) + log.G(ctx).Debugf("Creating a directory %q", f) if err = os.MkdirAll(f, 0o755); err != nil { return "", fmt.Errorf("failed to create a directory %q: %w", f, err) } @@ -147,10 +178,8 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse defer os.RemoveAll(tempDir) cidFilename := filepath.Join(tempDir, "cid") - var runFlagD bool - if !service.Unparsed.StdinOpen && !service.Unparsed.Tty { - container.RunArgs = append([]string{"-d"}, container.RunArgs...) - runFlagD = true + if c.EnvFile != "" { + container.RunArgs = append([]string{"--env-file=" + c.EnvFile}, container.RunArgs...) } //add metadata labels to container https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels @@ -162,15 +191,27 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse cmd := c.createNerdctlCmd(ctx, append([]string{"run"}, container.RunArgs...)...) if c.DebugPrintFull { - logrus.Debugf("Running %v", cmd.Args) + log.G(ctx).Debugf("Running %v", cmd.Args) } - // FIXME - if service.Unparsed.StdinOpen != service.Unparsed.Tty { - return "", fmt.Errorf("currently StdinOpen(-i) and Tty(-t) should be same") + if err := c.executeUpCmd(ctx, cmd, container.Name, runFlagD, service.Unparsed.StdinOpen); err != nil { + return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) + } + + cid, err := os.ReadFile(cidFilename) + if err != nil { + return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) } + return strings.TrimSpace(string(cid)), nil +} - if service.Unparsed.StdinOpen { +func (c *Composer) executeUpCmd(ctx context.Context, cmd *exec.Cmd, containerName string, runFlagD, stdinOpen bool) error { + log.G(ctx).Infof("Running %v", cmd.Args) + if c.DebugPrintFull { + log.G(ctx).Debugf("Running %v", cmd.Args) + } + + if stdinOpen { cmd.Stdin = os.Stdin } if !runFlagD { @@ -179,14 +220,9 @@ func (c *Composer) upServiceContainer(ctx context.Context, service *serviceparse // Always propagate stderr to print detailed error messages (https://github.com/containerd/nerdctl/issues/1942) cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) + if err := cmd.Run(); err != nil { + return fmt.Errorf("error while creating container %s: %w", containerName, err) } - cid, err := os.ReadFile(cidFilename) - if err != nil { - return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) - } - return strings.TrimSpace(string(cid)), nil + return nil } diff --git a/pkg/composer/up_volume.go b/pkg/composer/up_volume.go index 3e8e26c5012..0ab0db1fd7a 100644 --- a/pkg/composer/up_volume.go +++ b/pkg/composer/up_volume.go @@ -20,10 +20,10 @@ import ( "context" "fmt" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/reflectutil" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) func (c *Composer) upVolume(ctx context.Context, shortName string) error { @@ -31,22 +31,25 @@ func (c *Composer) upVolume(ctx context.Context, shortName string) error { if !ok { return fmt.Errorf("invalid volume name %q", shortName) } - if vol.External.External { + if vol.External { // NOP return nil } if unknown := reflectutil.UnknownNonEmptyFields(&vol, "Name"); len(unknown) > 0 { - logrus.Warnf("Ignoring: volume %s: %+v", shortName, unknown) + log.G(ctx).Warnf("Ignoring: volume %s: %+v", shortName, unknown) } // shortName is like "db_data", fullName is like "compose-wordpress_db_data" fullName := vol.Name + // FIXME: this is racy. By the time we get below to creating the volume, there is no guarantee that things are still fine. + // Furthermore, by the time we are done creating all the volumes, they may very well have been destroyed. + // This cannot be fixed without getting rid of the whole "shell-out" approach entirely. volExists, err := c.VolumeExists(fullName) if err != nil { return err } else if !volExists { - logrus.Infof("Creating volume %s", fullName) + log.G(ctx).Infof("Creating volume %s", fullName) //add metadata labels to volume https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels-2 createArgs := []string{ fmt.Sprintf("--label=%s=%s", labels.ComposeProject, c.project.Name), diff --git a/pkg/config/config.go b/pkg/config/config.go index e393933779d..1666ab61a0e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,10 +17,10 @@ package config import ( - "github.com/containerd/containerd" - "github.com/containerd/containerd/defaults" - "github.com/containerd/containerd/namespaces" - ncdefaults "github.com/containerd/nerdctl/pkg/defaults" + "github.com/containerd/containerd/v2/defaults" + "github.com/containerd/containerd/v2/pkg/namespaces" + + ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" ) // Config corresponds to nerdctl.toml . @@ -39,6 +39,8 @@ type Config struct { HostsDir []string `toml:"hosts_dir"` Experimental bool `toml:"experimental"` HostGatewayIP string `toml:"host_gateway_ip"` + BridgeIP string `toml:"bridge_ip, omitempty"` + KubeHideDupe bool `toml:"kube_hide_dupe"` } // New creates a default Config object statically, @@ -49,7 +51,7 @@ func New() *Config { DebugFull: false, Address: defaults.DefaultAddress, Namespace: namespaces.Default, - Snapshotter: containerd.DefaultSnapshotter, + Snapshotter: defaults.DefaultSnapshotter, CNIPath: ncdefaults.CNIPath(), CNINetConfPath: ncdefaults.CNINetConfPath(), DataRoot: ncdefaults.DataRoot(), @@ -58,5 +60,6 @@ func New() *Config { HostsDir: ncdefaults.HostsDirs(), Experimental: true, HostGatewayIP: ncdefaults.HostGatewayIP(), + KubeHideDupe: false, } } diff --git a/pkg/consoleutil/consoleutil.go b/pkg/consoleutil/consoleutil.go index 4f9167a0b4c..a19988a6895 100644 --- a/pkg/consoleutil/consoleutil.go +++ b/pkg/consoleutil/consoleutil.go @@ -18,8 +18,22 @@ package consoleutil import ( "context" + "os" + + "github.com/containerd/console" ) +// Current is from https://github.com/containerd/console/blob/v1.0.4/console.go#L68-L81 +// adapted so that it does not panic +func Current() (c console.Console, err error) { + for _, s := range []*os.File{os.Stderr, os.Stdout, os.Stdin} { + if c, err = console.ConsoleFromFile(s); err == nil { + return c, nil + } + } + return nil, console.ErrNotAConsole +} + // resizer is from https://github.com/containerd/containerd/blob/v1.7.0-rc.2/cmd/ctr/commands/tasks/tasks.go#L25-L27 type resizer interface { Resize(ctx context.Context, w, h uint32) error diff --git a/pkg/consoleutil/consoleutil_unix.go b/pkg/consoleutil/consoleutil_unix.go index 17421a6fed4..5cc05685021 100644 --- a/pkg/consoleutil/consoleutil_unix.go +++ b/pkg/consoleutil/consoleutil_unix.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build unix /* Copyright The containerd Authors. @@ -19,18 +19,19 @@ package consoleutil import ( - gocontext "context" + "context" "os" "os/signal" - "github.com/containerd/console" - "github.com/containerd/containerd/log" "golang.org/x/sys/unix" + + "github.com/containerd/console" + "github.com/containerd/log" ) // HandleConsoleResize resizes the console. // From https://github.com/containerd/containerd/blob/v1.7.0-rc.2/cmd/ctr/commands/tasks/tasks_unix.go#L43-L68 -func HandleConsoleResize(ctx gocontext.Context, task resizer, con console.Console) error { +func HandleConsoleResize(ctx context.Context, task resizer, con console.Console) error { // do an initial resize of the console size, err := con.Size() if err != nil { diff --git a/pkg/consoleutil/consoleutil_windows.go b/pkg/consoleutil/consoleutil_windows.go index ee823786f2a..4ae19c3cc57 100644 --- a/pkg/consoleutil/consoleutil_windows.go +++ b/pkg/consoleutil/consoleutil_windows.go @@ -17,16 +17,16 @@ package consoleutil import ( - gocontext "context" + "context" "time" "github.com/containerd/console" - "github.com/containerd/containerd/log" + "github.com/containerd/log" ) // HandleConsoleResize resizes the console. // From https://github.com/containerd/containerd/blob/v1.7.0-rc.2/cmd/ctr/commands/tasks/tasks_windows.go#L34-L61 -func HandleConsoleResize(ctx gocontext.Context, task resizer, con console.Console) error { +func HandleConsoleResize(ctx context.Context, task resizer, con console.Console) error { // do an initial resize of the console size, err := con.Size() if err != nil { diff --git a/pkg/consoleutil/detach.go b/pkg/consoleutil/detach.go index 94b550cb143..daee28ee8f9 100644 --- a/pkg/consoleutil/detach.go +++ b/pkg/consoleutil/detach.go @@ -22,7 +22,8 @@ import ( "io" "github.com/moby/term" - "github.com/sirupsen/logrus" + + "github.com/containerd/log" ) const DefaultDetachKeys = "ctrl-p,ctrl-q" @@ -53,7 +54,7 @@ func (ds *detachableStdin) Read(p []byte) (int, error) { n, err := ds.stdin.Read(p) var eerr term.EscapeError if errors.As(err, &eerr) { - logrus.Info("read detach keys") + log.L.Info("read detach keys") if ds.closer != nil { ds.closer() } diff --git a/pkg/containerdutil/content.go b/pkg/containerdutil/content.go new file mode 100644 index 00000000000..929e60951c9 --- /dev/null +++ b/pkg/containerdutil/content.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 containerdutil provides "caching" versions of containerd native snapshotter and content store. +// NOTE: caching should only be used for single, atomic operations, like `nerdctl images`, and NOT kept +// across successive, distincts operations. As such, caching is not persistent across invocations of nerdctl, +// and only lasts as long as the lifetime of the Snapshotter or ContentStore. +package containerdutil + +import ( + "context" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" +) + +// ContentStore should be called to get a Provider with caching +func NewProvider(client *containerd.Client) content.Provider { + return &providerWithCache{ + client.ContentStore(), + make(map[string]*readerAtWithCache), + } +} + +type providerWithCache struct { + native content.Provider + cache map[string]*readerAtWithCache +} + +func (provider *providerWithCache) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { + key := desc.Digest.String() + // If we had en entry already, get the size over + value, ok := provider.cache[key] + if !ok { + newReaderAt, err := provider.native.ReaderAt(ctx, desc) + if err != nil { + return nil, err + } + // Build the final object + value = &readerAtWithCache{ + newReaderAt, + -1, + func() { + delete(provider.cache, key) + }, + } + // Cache it + provider.cache[key] = value + } + + return value, nil +} + +// ReaderAtWithCache implements the content.ReaderAt interface +type readerAtWithCache struct { + content.ReaderAt + size int64 + prune func() +} + +func (rac *readerAtWithCache) Size() int64 { + // local implementation in containerd technically provides a similar mechanism, so, this method not really useful + // by default - but obviously, this is implementation dependent + if rac.size == -1 { + rac.size = rac.ReaderAt.Size() + } + return rac.size +} + +func (rac *readerAtWithCache) Close() error { + err := rac.ReaderAt.Close() + // Remove ourselves from the cache + rac.prune() + return err +} diff --git a/pkg/containerdutil/helpers.go b/pkg/containerdutil/helpers.go new file mode 100644 index 00000000000..6f4e7a8a451 --- /dev/null +++ b/pkg/containerdutil/helpers.go @@ -0,0 +1,46 @@ +/* + 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" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/content" +) + +var ReadBlob = readBlobWithCache() + +type readBlob func(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) ([]byte, error) + +func readBlobWithCache() readBlob { + var cache = make(map[string]([]byte)) + + return func(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) ([]byte, error) { + var err error + v, ok := cache[desc.Digest.String()] + if !ok { + v, err = content.ReadBlob(ctx, provider, desc) + if err == nil { + cache[desc.Digest.String()] = v + } + } + + return v, err + } +} diff --git a/pkg/containerdutil/snapshotter.go b/pkg/containerdutil/snapshotter.go new file mode 100644 index 00000000000..bf35171b5cb --- /dev/null +++ b/pkg/containerdutil/snapshotter.go @@ -0,0 +1,61 @@ +/* + 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" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" +) + +// SnapshotService should be called to get a new caching snapshotter +func SnapshotService(client *containerd.Client, snapshotterName string) snapshots.Snapshotter { + return &snapshotterWithCache{ + client.SnapshotService(snapshotterName), + map[string]snapshots.Info{}, + map[string]snapshots.Usage{}, + } +} + +type snapshotterWithCache struct { + snapshots.Snapshotter + statCache map[string]snapshots.Info + usageCache map[string]snapshots.Usage +} + +func (snap *snapshotterWithCache) Stat(ctx context.Context, key string) (snapshots.Info, error) { + if stat, ok := snap.statCache[key]; ok { + return stat, nil + } + stat, err := snap.Snapshotter.Stat(ctx, key) + if err == nil { + snap.statCache[key] = stat + } + return stat, err +} + +func (snap *snapshotterWithCache) Usage(ctx context.Context, key string) (snapshots.Usage, error) { + if usage, ok := snap.usageCache[key]; ok { + return usage, nil + } + usage, err := snap.Snapshotter.Usage(ctx, key) + if err == nil { + snap.usageCache[key] = usage + } + return usage, err +} diff --git a/pkg/containerinspector/containerinspector.go b/pkg/containerinspector/containerinspector.go index 8f00eb5bf85..a3a77e1e60d 100644 --- a/pkg/containerinspector/containerinspector.go +++ b/pkg/containerinspector/containerinspector.go @@ -19,10 +19,12 @@ package containerinspector import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" "github.com/containerd/typeurl/v2" - "github.com/sirupsen/logrus" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) func Inspect(ctx context.Context, container containerd.Container) (*native.Container, error) { @@ -37,12 +39,14 @@ func Inspect(ctx context.Context, container containerd.Container) (*native.Conta n.Spec, err = typeurl.UnmarshalAny(info.Spec) if err != nil { - logrus.WithError(err).WithField("id", id).Warnf("failed to inspect Spec") + log.G(ctx).WithError(err).WithField("id", id).Warnf("failed to inspect Spec") return n, nil } task, err := container.Task(ctx, nil) if err != nil { - logrus.WithError(err).WithField("id", id).Warnf("failed to inspect Task") + if !errdefs.IsNotFound(err) { + log.G(ctx).WithError(err).WithField("id", id).Warnf("failed to inspect Task") + } return n, nil } n.Process = &native.Process{ @@ -50,13 +54,13 @@ func Inspect(ctx context.Context, container containerd.Container) (*native.Conta } st, err := task.Status(ctx) if err != nil { - logrus.WithError(err).WithField("id", id).Warnf("failed to inspect Status") + log.G(ctx).WithError(err).WithField("id", id).Warnf("failed to inspect Status") return n, nil } n.Process.Status = st netNS, err := InspectNetNS(ctx, n.Process.Pid) if err != nil { - logrus.WithError(err).WithField("id", id).Warnf("failed to inspect NetNS") + log.G(ctx).WithError(err).WithField("id", id).Warnf("failed to inspect NetNS") return n, nil } n.Process.NetNS = netNS diff --git a/pkg/containerinspector/containerinspector_freebsd.go b/pkg/containerinspector/containerinspector_freebsd.go index c9c53558955..e5a2dbc42fb 100644 --- a/pkg/containerinspector/containerinspector_freebsd.go +++ b/pkg/containerinspector/containerinspector_freebsd.go @@ -19,7 +19,7 @@ package containerinspector import ( "context" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) func InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) { diff --git a/pkg/containerinspector/containerinspector_linux.go b/pkg/containerinspector/containerinspector_linux.go index b409166d85e..729f878b764 100644 --- a/pkg/containerinspector/containerinspector_linux.go +++ b/pkg/containerinspector/containerinspector_linux.go @@ -22,9 +22,9 @@ import ( "net" "strings" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containernetworking/plugins/pkg/ns" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) func InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) { diff --git a/pkg/containerinspector/containerinspector_windows.go b/pkg/containerinspector/containerinspector_windows.go index c9c53558955..e5a2dbc42fb 100644 --- a/pkg/containerinspector/containerinspector_windows.go +++ b/pkg/containerinspector/containerinspector_windows.go @@ -19,7 +19,7 @@ package containerinspector import ( "context" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) func InspectNetNS(ctx context.Context, pid int) (*native.NetNS, error) { diff --git a/pkg/containerutil/config.go b/pkg/containerutil/config.go index dfe4ec47e93..4fb5ce45546 100644 --- a/pkg/containerutil/config.go +++ b/pkg/containerutil/config.go @@ -24,11 +24,14 @@ import ( "runtime" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/netutil/nettype" "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/ipcutil" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" ) // ReconfigNetContainer reconfigures the container's network namespace path. @@ -104,3 +107,26 @@ func ReconfigPIDContainer(ctx context.Context, c containerd.Container, client *c } return nil } + +// ReconfigIPCContainer reconfigures the container's spec options for sharing IPC namespace and volumns. +func ReconfigIPCContainer(ctx context.Context, c containerd.Container, client *containerd.Client, lab map[string]string) error { + ipc, err := ipcutil.DecodeIPCLabel(lab[labels.IPC]) + if err != nil { + return err + } + opts, err := ipcutil.GenerateIPCOpts(ctx, ipc, client) + if err != nil { + return err + } + spec, err := c.Spec(ctx) + if err != nil { + return err + } + err = c.Update(ctx, containerd.UpdateContainerOpts( + containerd.WithSpec(spec, oci.Compose(opts...)), + )) + if err != nil { + return err + } + return nil +} diff --git a/pkg/containerutil/container_network_manager.go b/pkg/containerutil/container_network_manager.go index 77e8dfaeabc..23852f56f58 100644 --- a/pkg/containerutil/container_network_manager.go +++ b/pkg/containerutil/container_network_manager.go @@ -27,20 +27,23 @@ import ( "runtime" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/netns" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/dnsutil/hostsstore" - "github.com/containerd/nerdctl/pkg/idutil/containerwalker" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/mountutil" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/netutil/nettype" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + "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/dnsutil/hostsstore" + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/mountutil" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/netutil/nettype" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) const ( @@ -83,23 +86,23 @@ func withCustomHosts(src string) func(context.Context, oci.Client, *containers.C } } -// types.NetworkOptionsManager is an interface for reading/setting networking +// NetworkOptionsManager types.NetworkOptionsManager is an interface for reading/setting networking // options for containers based on the provided command flags. type NetworkOptionsManager interface { - // Returns a copy of the internal types.NetworkOptions. + // NetworkOptions Returns a copy of the internal types.NetworkOptions. NetworkOptions() types.NetworkOptions - // Verifies that the internal network settings are correct. + // VerifyNetworkOptions Verifies that the internal network settings are correct. VerifyNetworkOptions(context.Context) error - // Performs setup actions required for the container with the given ID. + // SetupNetworking Performs setup actions required for the container with the given ID. SetupNetworking(context.Context, string) error - // Performs any required cleanup actions for the given container. + // CleanupNetworking Performs any required cleanup actions for the given container. // Should only be called to revert any setup steps performed in SetupNetworking. CleanupNetworking(context.Context, containerd.Container) error - // Returns the set of NetworkingOptions which should be set as labels on the container. + // InternalNetworkingOptionLabels Returns the set of NetworkingOptions which should be set as labels on the container. // // These options can potentially differ from the actual networking options // that the NetworkOptionsManager was initially instantiated with. @@ -107,13 +110,13 @@ type NetworkOptionsManager interface { // `--net=container:myContainer` => `--net=container:`. InternalNetworkingOptionLabels(context.Context) (types.NetworkOptions, error) - // Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent + // ContainerNetworkingOpts Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent // the network specs which need to be applied to the container with the given ID. ContainerNetworkingOpts(context.Context, string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) } -// Returns a types.NetworkOptionsManager based on the provided command's flags. -func NewNetworkingOptionsManager(globalOptions types.GlobalCommandOptions, netOpts types.NetworkOptions) (NetworkOptionsManager, error) { +// NewNetworkingOptionsManager Returns a types.NetworkOptionsManager based on the provided command's flags. +func NewNetworkingOptionsManager(globalOptions types.GlobalCommandOptions, netOpts types.NetworkOptions, client *containerd.Client) (NetworkOptionsManager, error) { netType, err := nettype.Detect(netOpts.NetworkSlice) if err != nil { return nil, err @@ -122,13 +125,17 @@ func NewNetworkingOptionsManager(globalOptions types.GlobalCommandOptions, netOp var manager NetworkOptionsManager switch netType { case nettype.None: - manager = &noneNetworkManager{globalOptions, netOpts} + manager = &noneNetworkManager{globalOptions, netOpts, client} case nettype.Host: - manager = &hostNetworkManager{globalOptions, netOpts} + manager = &hostNetworkManager{globalOptions, netOpts, client} case nettype.Container: - manager = &containerNetworkManager{globalOptions, netOpts} + manager = &containerNetworkManager{globalOptions, netOpts, client} case nettype.CNI: - manager = &cniNetworkManager{globalOptions, netOpts, nil} + manager = &cniNetworkManager{globalOptions, netOpts, client, cniNetworkManagerPlatform{}} + case nettype.Namespace: + // We'll handle Namespace networking identically to Host-mode networking, but + // put the container in the specified network namespace instead of the root. + manager = &hostNetworkManager{globalOptions, netOpts, client} default: return nil, fmt.Errorf("unexpected container networking type: %q", netType) } @@ -140,36 +147,37 @@ func NewNetworkingOptionsManager(globalOptions types.GlobalCommandOptions, netOp type noneNetworkManager struct { globalOptions types.GlobalCommandOptions netOpts types.NetworkOptions + client *containerd.Client } -// Returns a copy of the internal types.NetworkOptions. +// NetworkOptions Returns a copy of the internal types.NetworkOptions. func (m *noneNetworkManager) NetworkOptions() types.NetworkOptions { return m.netOpts } -// Verifies that the internal network settings are correct. +// VerifyNetworkOptions Verifies that the internal network settings are correct. func (m *noneNetworkManager) VerifyNetworkOptions(_ context.Context) error { // No options to verify if no network settings are provided. return nil } -// Performs setup actions required for the container with the given ID. +// SetupNetworking Performs setup actions required for the container with the given ID. func (m *noneNetworkManager) SetupNetworking(_ context.Context, _ string) error { return nil } -// Performs any required cleanup actions for the given container. +// CleanupNetworking Performs any required cleanup actions for the given container. // Should only be called to revert any setup steps performed in SetupNetworking. func (m *noneNetworkManager) CleanupNetworking(_ context.Context, _ containerd.Container) error { return nil } -// Returns the set of NetworkingOptions which should be set as labels on the container. +// InternalNetworkingOptionLabels Returns the set of NetworkingOptions which should be set as labels on the container. func (m *noneNetworkManager) InternalNetworkingOptionLabels(_ context.Context) (types.NetworkOptions, error) { return m.netOpts, nil } -// Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent +// ContainerNetworkingOpts Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent // the network specs which need to be applied to the container with the given ID. func (m *noneNetworkManager) ContainerNetworkingOpts(_ context.Context, _ string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { // No options to return if no network settings are provided. @@ -180,27 +188,28 @@ func (m *noneNetworkManager) ContainerNetworkingOpts(_ context.Context, _ string type containerNetworkManager struct { globalOptions types.GlobalCommandOptions netOpts types.NetworkOptions + client *containerd.Client } -// Returns a copy of the internal types.NetworkOptions. +// NetworkOptions Returns a copy of the internal types.NetworkOptions. func (m *containerNetworkManager) NetworkOptions() types.NetworkOptions { return m.netOpts } -// Verifies that the internal network settings are correct. +// VerifyNetworkOptions Verifies that the internal network settings are correct. func (m *containerNetworkManager) VerifyNetworkOptions(_ context.Context) error { // TODO: check host OS, not client-side OS. if runtime.GOOS != "linux" { return errors.New("container networking mode is currently only supported on Linux") } - if m.netOpts.NetworkSlice != nil && len(m.netOpts.NetworkSlice) > 1 { + if len(m.netOpts.NetworkSlice) > 1 { return errors.New("conflicting options: only one network specification is allowed when using '--network=container:'") } + // Note that mac-address is accepted, though it is a no-op nonZeroParams := nonZeroMapValues(map[string]interface{}{ - "--hostname": m.netOpts.Hostname, - "--mac-address": m.netOpts.MACAddress, + "--hostname": m.netOpts.Hostname, // NOTE: an empty slice still counts as a non-zero value so we check its length: "-p/--publish": len(m.netOpts.PortMappings) != 0, "--dns": len(m.netOpts.DNSServers) != 0, @@ -225,22 +234,30 @@ func (m *containerNetworkManager) getContainerNetworkFilePaths(containerID strin if err != nil { return "", "", "", err } + hostsStore, err := hostsstore.New(dataStore, m.globalOptions.Namespace) + if err != nil { + return "", "", "", err + } hostnamePath := filepath.Join(conStateDir, "hostname") resolvConfPath := filepath.Join(conStateDir, "resolv.conf") - etcHostsPath := hostsstore.HostsPath(dataStore, m.globalOptions.Namespace, containerID) + + etcHostsPath, err := hostsStore.HostsPath(containerID) + if err != nil { + return "", "", "", err + } return hostnamePath, resolvConfPath, etcHostsPath, nil } -// Performs setup actions required for the container with the given ID. +// SetupNetworking Performs setup actions required for the container with the given ID. func (m *containerNetworkManager) SetupNetworking(_ context.Context, _ string) error { // NOTE: container networking simply reuses network config files from the // bridged container so there are no setup/teardown steps required. return nil } -// Performs any required cleanup actions for the given container. +// CleanupNetworking Performs any required cleanup actions for the given container. // Should only be called to revert any setup steps performed in SetupNetworking. func (m *containerNetworkManager) CleanupNetworking(_ context.Context, _ containerd.Container) error { // NOTE: container networking simply reuses network config files from the @@ -249,19 +266,13 @@ func (m *containerNetworkManager) CleanupNetworking(_ context.Context, _ contain } // Searches for and returns the networking container for the given network argument. -func (m *containerNetworkManager) getNetworkingContainerForArgument(ctx context.Context, containerNetArg string) (containerd.Container, error) { +func (m *containerNetworkManager) getNetworkingContainerForArgument(ctx context.Context, containerNetArg string, client *containerd.Client) (containerd.Container, error) { netItems := strings.Split(containerNetArg, ":") if len(netItems) < 2 { return nil, fmt.Errorf("container networking argument format must be 'container:', got: %q", containerNetArg) } containerName := netItems[1] - client, ctxt, cancel, err := clientutil.NewClient(ctx, m.globalOptions.Namespace, m.globalOptions.Address) - if err != nil { - return nil, err - } - defer cancel() - var foundContainer containerd.Container walker := &containerwalker.ContainerWalker{ Client: client, @@ -273,7 +284,7 @@ func (m *containerNetworkManager) getNetworkingContainerForArgument(ctx context. return nil }, } - n, err := walker.Walk(ctxt, containerName) + n, err := walker.Walk(ctx, containerName) if err != nil { return nil, err } @@ -284,14 +295,16 @@ func (m *containerNetworkManager) getNetworkingContainerForArgument(ctx context. return foundContainer, nil } -// Returns the set of NetworkingOptions which should be set as labels on the container. +// InternalNetworkingOptionLabels Returns the set of NetworkingOptions which should be set as labels on the container. func (m *containerNetworkManager) InternalNetworkingOptionLabels(ctx context.Context) (types.NetworkOptions, error) { opts := m.netOpts if m.netOpts.NetworkSlice == nil || len(m.netOpts.NetworkSlice) != 1 { return opts, fmt.Errorf("conflicting options: exactly one network specification is allowed when using '--network=container:'") } + // MacAddress is not allowed with container networking + opts.MACAddress = "" - container, err := m.getNetworkingContainerForArgument(ctx, m.netOpts.NetworkSlice[0]) + container, err := m.getNetworkingContainerForArgument(ctx, m.netOpts.NetworkSlice[0], m.client) if err != nil { return opts, err } @@ -300,13 +313,13 @@ func (m *containerNetworkManager) InternalNetworkingOptionLabels(ctx context.Con return opts, nil } -// Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent +// ContainerNetworkingOpts Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent // the network specs which need to be applied to the container with the given ID. func (m *containerNetworkManager) ContainerNetworkingOpts(ctx context.Context, _ string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { opts := []oci.SpecOpts{} cOpts := []containerd.NewContainerOpts{} - container, err := m.getNetworkingContainerForArgument(ctx, m.netOpts.NetworkSlice[0]) + container, err := m.getNetworkingContainerForArgument(ctx, m.netOpts.NetworkSlice[0], m.client) if err != nil { return nil, nil, err } @@ -346,41 +359,96 @@ func (m *containerNetworkManager) ContainerNetworkingOpts(ctx context.Context, _ type hostNetworkManager struct { globalOptions types.GlobalCommandOptions netOpts types.NetworkOptions + client *containerd.Client } -// Returns a copy of the internal types.NetworkOptions. +// NetworkOptions Returns a copy of the internal types.NetworkOptions. func (m *hostNetworkManager) NetworkOptions() types.NetworkOptions { return m.netOpts } -// Verifies that the internal network settings are correct. +// VerifyNetworkOptions Verifies that the internal network settings are correct. func (m *hostNetworkManager) VerifyNetworkOptions(_ context.Context) error { // TODO: check host OS, not client-side OS. if runtime.GOOS == "windows" { return errors.New("cannot use host networking on Windows") } - if m.netOpts.MACAddress != "" { - return errors.New("conflicting options: mac-address and the network mode") - } - return validateUtsSettings(m.netOpts) } -// Performs setup actions required for the container with the given ID. -func (m *hostNetworkManager) SetupNetworking(_ context.Context, _ string) error { - // NOTE: there are no setup steps required for host networking. - return nil +// SetupNetworking Performs setup actions required for the container with the given ID. +func (m *hostNetworkManager) SetupNetworking(ctx context.Context, containerID string) error { + // Retrieve the container + container, err := m.client.ContainerService().Get(ctx, containerID) + if err != nil { + return err + } + + // Get the dataStore + dataStore, err := clientutil.DataStore(m.globalOptions.DataRoot, m.globalOptions.Address) + if err != nil { + return err + } + + // Get the hostsStore + hs, err := hostsstore.New(dataStore, container.Labels[labels.Namespace]) + if err != nil { + return err + } + + // Get extra-hosts + extraHostsJSON := container.Labels[labels.ExtraHosts] + var extraHosts []string + if err = json.Unmarshal([]byte(extraHostsJSON), &extraHosts); err != nil { + return err + } + + hosts := make(map[string]string) + for _, host := range extraHosts { + if v := strings.SplitN(host, ":", 2); len(v) == 2 { + hosts[v[0]] = v[1] + } + } + + // Prep the meta + hsMeta := hostsstore.Meta{ + ID: container.ID, + Hostname: container.Labels[labels.Hostname], + ExtraHosts: hosts, + Name: container.Labels[labels.Name], + } + + // Save the meta information + return hs.Acquire(hsMeta) } -// Performs any required cleanup actions for the given container. +// CleanupNetworking Performs any required cleanup actions for the given container. // Should only be called to revert any setup steps performed in SetupNetworking. -func (m *hostNetworkManager) CleanupNetworking(_ context.Context, _ containerd.Container) error { - // NOTE: there are no setup steps required for host networking. - return nil +func (m *hostNetworkManager) CleanupNetworking(ctx context.Context, container containerd.Container) error { + // Get the dataStore + dataStore, err := clientutil.DataStore(m.globalOptions.DataRoot, m.globalOptions.Address) + if err != nil { + return err + } + + // Get labels + lbls, err := container.Labels(ctx) + if err != nil { + return err + } + + // Get the hostsStore + hs, err := hostsstore.New(dataStore, lbls[labels.Namespace]) + if err != nil { + return err + } + + // Release + return hs.Release(container.ID()) } -// Returns the set of NetworkingOptions which should be set as labels on the container. +// InternalNetworkingOptionLabels Returns the set of NetworkingOptions which should be set as labels on the container. func (m *hostNetworkManager) InternalNetworkingOptionLabels(_ context.Context) (types.NetworkOptions, error) { opts := m.netOpts // Cannot have a MAC address in host networking mode. @@ -388,25 +456,115 @@ func (m *hostNetworkManager) InternalNetworkingOptionLabels(_ context.Context) ( return opts, nil } -// Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent +// withDedupMounts Returns the specOpts if the mountPath is not in existing mounts. +// for https://github.com/containerd/nerdctl/issues/2685 +func withDedupMounts(mountPath string, defaultSpec oci.SpecOpts) oci.SpecOpts { + return func(ctx context.Context, client oci.Client, c *containers.Container, s *oci.Spec) error { + for _, m := range s.Mounts { + if m.Destination == mountPath { + return nil + } + } + return defaultSpec(ctx, client, c, s) + } +} + +// copyFileContent copies a file and sets world readable permissions on it, regardless of umask. +// This is used solely for /etc/resolv.conf and /etc/hosts +func copyFileContent(src string, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + err = os.WriteFile(dst, data, 0644) + if err != nil { + return err + } + err = os.Chmod(dst, 0644) + if err != nil { + return err + } + return nil +} + +// getHostNetworkingNamespace Returns an oci.SpecOpts representing the network namespace to +// be used by the hostNetworkManager. When running with `--network=host` this would be the host's +// root namespace, but `--network=ns:` can be used to run a container in an existing netns. +func getHostNetworkingNamespace(netModeArg string) (oci.SpecOpts, error) { + if !strings.Contains(netModeArg, ":") { + // Use the host root namespace by default + return oci.WithHostNamespace(specs.NetworkNamespace), nil + } + + netItems := strings.Split(netModeArg, ":") + if len(netItems) < 2 { + return nil, fmt.Errorf("namespace networking argument format must be 'ns:', got: %q", netModeArg) + } + netnsPath := netItems[1] + return oci.WithLinuxNamespace(specs.LinuxNamespace{ + Type: specs.NetworkNamespace, + Path: netnsPath, + }), nil +} + +// ContainerNetworkingOpts Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent // the network specs which need to be applied to the container with the given ID. func (m *hostNetworkManager) ContainerNetworkingOpts(_ context.Context, containerID string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { cOpts := []containerd.NewContainerOpts{} + + dataStore, err := clientutil.DataStore(m.globalOptions.DataRoot, m.globalOptions.Address) + if err != nil { + return nil, nil, err + } + + stateDir, err := ContainerStateDirPath(m.globalOptions.Namespace, dataStore, containerID) + if err != nil { + return nil, nil, err + } + + resolvConfPath := filepath.Join(stateDir, "resolv.conf") + copyFileContent("/etc/resolv.conf", resolvConfPath) + + hs, err := hostsstore.New(dataStore, m.globalOptions.Namespace) + if err != nil { + return nil, nil, err + } + + content, err := os.ReadFile("/etc/hosts") + if err != nil { + return nil, nil, err + } + + etcHostsPath, err := hs.AllocHostsFile(containerID, content) + if err != nil { + return nil, nil, err + } + + netModeArg := m.netOpts.NetworkSlice[0] + netNamespace, err := getHostNetworkingNamespace(netModeArg) + if err != nil { + return nil, nil, err + } specs := []oci.SpecOpts{ - oci.WithHostNamespace(specs.NetworkNamespace), - oci.WithHostHostsFile, - oci.WithHostResolvconf, + netNamespace, + withDedupMounts("/etc/hosts", withCustomHosts(etcHostsPath)), + withDedupMounts("/etc/resolv.conf", withCustomResolvConf(resolvConfPath)), } // `/etc/hostname` does not exist on FreeBSD if runtime.GOOS == "linux" && m.netOpts.UTSNamespace != UtsNamespaceHost { - // If no hostname is set, default to first 12 characters of the container ID. hostname := m.netOpts.Hostname if hostname == "" { - hostname = containerID - if len(hostname) > 12 { - hostname = hostname[0:12] + // Hostname by default should be the host hostname + hostname, err = os.Hostname() + if err != nil { + log.L.WithError(err).Warn("could not get hostname") + // If no hostname is set, default to first 12 characters of the container ID. + hostname = containerID + if len(hostname) > 12 { + hostname = hostname[0:12] + } } } m.netOpts.Hostname = hostname @@ -420,19 +578,60 @@ func (m *hostNetworkManager) ContainerNetworkingOpts(_ context.Context, containe } } + if rootlessutil.IsRootless() { + detachedNetNS, err := rootlessutil.DetachedNetNS() + if err != nil { + return nil, nil, fmt.Errorf("failed to check whether RootlessKit is running with --detach-netns: %w", err) + } + if detachedNetNS != "" { + // For rootless + host netns, we can't mount sysfs. + // We can't (non-recursively) bind mount /sys, either. + // + // TODO: consider to just rbind /sys from the host with rro, + // when rro is available (kernel >= 5.12, runc >= 1.1). + // + // Relevant: https://github.com/moby/buildkit/blob/v0.12.4/util/rootless/specconv/specconv_linux.go#L15-L34 + specs = append(specs, withRemoveSysfs) + } + } + return specs, cOpts, nil } +func withRemoveSysfs(_ context.Context, _ oci.Client, c *containers.Container, s *oci.Spec) error { + var hasSysfs bool + for _, mount := range s.Mounts { + if mount.Type == "sysfs" { + hasSysfs = true + break + } + } + if !hasSysfs { + // NOP, as the user has specified a custom /sys mount + return nil + } + var mounts []specs.Mount // nolint: prealloc + for _, mount := range s.Mounts { + if strings.HasPrefix(mount.Destination, "/sys") { + continue + } + mounts = append(mounts, mount) + } + s.Mounts = mounts + return nil +} + // types.NetworkOptionsManager implementation for CNI networking settings. // This is a more specialized and OS-dependendant networking model so this // struct provides different implementations on different platforms. type cniNetworkManager struct { globalOptions types.GlobalCommandOptions netOpts types.NetworkOptions - netNs *netns.NetNS + client *containerd.Client + cniNetworkManagerPlatform } -// Returns a copy of the internal types.NetworkOptions. +// NetworkOptions Returns a copy of the internal types.NetworkOptions. func (m *cniNetworkManager) NetworkOptions() types.NetworkOptions { return m.netOpts } @@ -456,6 +655,7 @@ func validateUtsSettings(netOpts types.NetworkOptions) error { // Nerdctl-managed datastore and returns the oci.SpecOpts required in the container // spec for the file to be mounted under /etc/hostname in the new container. // If the hostname is empty, the leading 12 characters of the containerID +// This sets world readable permissions on /etc/hostname, ignoring umask func writeEtcHostnameForContainer(globalOptions types.GlobalCommandOptions, hostname string, containerID string) ([]oci.SpecOpts, error) { if containerID == "" { return nil, fmt.Errorf("container ID is required for setting up hostname file") @@ -476,23 +676,26 @@ func writeEtcHostnameForContainer(globalOptions types.GlobalCommandOptions, host return nil, err } + err = os.Chmod(hostnamePath, 0644) + if err != nil { + return nil, err + } + return []oci.SpecOpts{oci.WithHostname(hostname), withCustomEtcHostname(hostnamePath)}, nil } // Loads all available networks and verifies that every selected network // from the networkSlice is of a type within supportedTypes. +// nolint:unused func verifyNetworkTypes(env *netutil.CNIEnv, networkSlice []string, supportedTypes []string) (map[string]*netutil.NetworkConfig, error) { - netMap, err := env.NetworkMap() - if err != nil { - return nil, err - } - res := make(map[string]*netutil.NetworkConfig, len(networkSlice)) + var netConfig *netutil.NetworkConfig + var err error for _, netstr := range networkSlice { - netConfig, ok := netMap[netstr] - if !ok { - return nil, fmt.Errorf("network %s not found", netstr) + if netConfig, err = env.NetworkByNameOrID(netstr); err != nil { + return nil, err } + netType := netConfig.Plugins[0].Network.Type if supportedTypes != nil && !strutil.InStringSlice(supportedTypes, netType) { return nil, fmt.Errorf("network type %q is not supported for network mapping %q, must be one of: %v", netType, netstr, supportedTypes) @@ -504,7 +707,7 @@ func verifyNetworkTypes(env *netutil.CNIEnv, networkSlice []string, supportedTyp return res, nil } -// Returns the NetworkOptions used in a container's creation from its spec.Annotations. +// NetworkOptionsFromSpec Returns the NetworkOptions used in a container's creation from its spec.Annotations. func NetworkOptionsFromSpec(spec *specs.Spec) (types.NetworkOptions, error) { opts := types.NetworkOptions{} diff --git a/pkg/containerutil/container_network_manager_linux.go b/pkg/containerutil/container_network_manager_linux.go index 45bfd4523d0..8b535715ba7 100644 --- a/pkg/containerutil/container_network_manager_linux.go +++ b/pkg/containerutil/container_network_manager_linux.go @@ -22,21 +22,25 @@ import ( "io/fs" "path/filepath" - "github.com/containerd/containerd" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/clientutil" - "github.com/containerd/nerdctl/pkg/dnsutil" - "github.com/containerd/nerdctl/pkg/dnsutil/hostsstore" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/resolvconf" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + "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/dnsutil" + "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/resolvconf" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) +type cniNetworkManagerPlatform struct { +} + // Verifies that the internal network settings are correct. func (m *cniNetworkManager) VerifyNetworkOptions(_ context.Context) error { - e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithDefaultNetwork()) + e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithNamespace(m.globalOptions.Namespace), netutil.WithDefaultNetwork(m.globalOptions.BridgeIP)) if err != nil { return err } @@ -95,10 +99,16 @@ func (m *cniNetworkManager) ContainerNetworkingOpts(_ context.Context, container } // the content of /etc/hosts is created in OCI Hook - etcHostsPath, err := hostsstore.AllocHostsFile(dataStore, m.globalOptions.Namespace, containerID) + hs, err := hostsstore.New(dataStore, m.globalOptions.Namespace) if err != nil { return nil, nil, err } + + etcHostsPath, err := hs.AllocHostsFile(containerID, []byte("")) + if err != nil { + return nil, nil, err + } + opts = append(opts, withCustomResolvConf(resolvConfPath), withCustomHosts(etcHostsPath)) if m.netOpts.UTSNamespace != UtsNamespaceHost { @@ -149,7 +159,7 @@ func (m *cniNetworkManager) buildResolvConf(resolvConfPath string) error { } // if resolvConf file does't exist, using default resolvers conf = &resolvconf.File{} - logrus.WithError(err).Debugf("resolvConf file doesn't exist on host") + log.L.WithError(err).Debugf("resolvConf file doesn't exist on host") } conf, err = resolvconf.FilterResolvDNS(conf.Content, true) if err != nil { diff --git a/pkg/containerutil/container_network_manager_other.go b/pkg/containerutil/container_network_manager_other.go index 500872f57c3..feb97e20f74 100644 --- a/pkg/containerutil/container_network_manager_other.go +++ b/pkg/containerutil/container_network_manager_other.go @@ -1,4 +1,4 @@ -//go:build darwin || freebsd || netbsd || openbsd +//go:build !(linux || windows) /* Copyright The containerd Authors. @@ -23,11 +23,15 @@ import ( "fmt" "runtime" - "github.com/containerd/containerd" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/api/types" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) +type cniNetworkManagerPlatform struct { +} + // Verifies that the internal network settings are correct. func (m *cniNetworkManager) VerifyNetworkOptions(_ context.Context) error { return fmt.Errorf("CNI networking currently unsupported on %s", runtime.GOOS) diff --git a/pkg/containerutil/container_network_manager_windows.go b/pkg/containerutil/container_network_manager_windows.go index 88abaeb6073..6cde2b351d8 100644 --- a/pkg/containerutil/container_network_manager_windows.go +++ b/pkg/containerutil/container_network_manager_windows.go @@ -20,19 +20,23 @@ import ( "context" "fmt" - "github.com/containerd/containerd" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/netns" - gocni "github.com/containerd/go-cni" - - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/ocihook" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/netns" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/netutil" + "github.com/containerd/nerdctl/v2/pkg/ocihook" ) +type cniNetworkManagerPlatform struct { + netNs *netns.NetNS +} + // Verifies that the internal network settings are correct. func (m *cniNetworkManager) VerifyNetworkOptions(_ context.Context) error { - e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithDefaultNetwork()) + e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithNamespace(m.globalOptions.Namespace), netutil.WithDefaultNetwork(m.globalOptions.BridgeIP)) if err != nil { return err } @@ -62,26 +66,26 @@ func (m *cniNetworkManager) VerifyNetworkOptions(_ context.Context) error { return nil } -func (m *cniNetworkManager) getCNI() (gocni.CNI, error) { - e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithDefaultNetwork()) +func (m *cniNetworkManager) getCNI() (cni.CNI, error) { + e, err := netutil.NewCNIEnv(m.globalOptions.CNIPath, m.globalOptions.CNINetConfPath, netutil.WithNamespace(m.globalOptions.Namespace), netutil.WithDefaultNetwork(m.globalOptions.BridgeIP)) if err != nil { return nil, fmt.Errorf("failed to instantiate CNI env: %s", err) } - cniOpts := []gocni.Opt{ - gocni.WithPluginDir([]string{m.globalOptions.CNIPath}), - gocni.WithPluginConfDir(m.globalOptions.CNINetConfPath), + cniOpts := []cni.Opt{ + cni.WithPluginDir([]string{m.globalOptions.CNIPath}), + cni.WithPluginConfDir(m.globalOptions.CNINetConfPath), } if netMap, err := verifyNetworkTypes(e, m.netOpts.NetworkSlice, nil); err == nil { for _, netConf := range netMap { - cniOpts = append(cniOpts, gocni.WithConfListBytes(netConf.Bytes)) + cniOpts = append(cniOpts, cni.WithConfListBytes(netConf.Bytes)) } } else { return nil, err } - return gocni.New(cniOpts...) + return cni.New(cniOpts...) } // Performs setup actions required for the container with the given ID. @@ -114,12 +118,12 @@ func (m *cniNetworkManager) CleanupNetworking(ctx context.Context, container con return fmt.Errorf("failed to get container specs for networking cleanup: %s", err) } - netNsId, found := spec.Annotations[ocihook.NetworkNamespace] + netNsID, found := spec.Annotations[ocihook.NetworkNamespace] if !found { return fmt.Errorf("no %q annotation present on container with ID %s", ocihook.NetworkNamespace, containerID) } - return cni.Remove(ctx, containerID, netNsId, m.getCNINamespaceOpts()...) + return cni.Remove(ctx, containerID, netNsID, m.getCNINamespaceOpts()...) } // Returns the set of NetworkingOptions which should be set as labels on the container. @@ -167,10 +171,10 @@ func (m *cniNetworkManager) setupNetNs() (*netns.NetNS, error) { return ns, err } -// Returns the []gocni.NamespaceOpts to be used for CNI setup/teardown. -func (m *cniNetworkManager) getCNINamespaceOpts() []gocni.NamespaceOpts { - opts := []gocni.NamespaceOpts{ - gocni.WithLabels(map[string]string{ +// Returns the []cni.NamespaceOpts to be used for CNI setup/teardown. +func (m *cniNetworkManager) getCNINamespaceOpts() []cni.NamespaceOpts { + opts := []cni.NamespaceOpts{ + cni.WithLabels(map[string]string{ // allow loose CNI argument verification // FYI: https://github.com/containernetworking/cni/issues/560 "IgnoreUnknown": "1", @@ -178,15 +182,15 @@ func (m *cniNetworkManager) getCNINamespaceOpts() []gocni.NamespaceOpts { } if m.netOpts.MACAddress != "" { - opts = append(opts, gocni.WithArgs("MAC", m.netOpts.MACAddress)) + opts = append(opts, cni.WithArgs("MAC", m.netOpts.MACAddress)) } if m.netOpts.IPAddress != "" { - opts = append(opts, gocni.WithArgs("IP", m.netOpts.IPAddress)) + opts = append(opts, cni.WithArgs("IP", m.netOpts.IPAddress)) } if m.netOpts.PortMappings != nil { - opts = append(opts, gocni.WithCapabilityPortMap(m.netOpts.PortMappings)) + opts = append(opts, cni.WithCapabilityPortMap(m.netOpts.PortMappings)) } return opts diff --git a/pkg/containerutil/containerutil.go b/pkg/containerutil/containerutil.go index 2e695f02bfd..fca15cb6669 100644 --- a/pkg/containerutil/containerutil.go +++ b/pkg/containerutil/containerutil.go @@ -18,6 +18,7 @@ package containerutil import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -27,24 +28,31 @@ import ( "strings" "time" - "github.com/containerd/console" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/runtime/restart" - "github.com/containerd/nerdctl/pkg/consoleutil" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/formatter" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/nsutil" - "github.com/containerd/nerdctl/pkg/portutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/signalutil" - "github.com/containerd/nerdctl/pkg/taskutil" + dockercliopts "github.com/docker/cli/opts" + dockeropts "github.com/docker/docker/opts" "github.com/moby/sys/signal" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + + "github.com/containerd/console" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/core/runtime/restart" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "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/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" + "github.com/containerd/nerdctl/v2/pkg/taskutil" ) // PrintHostPort writes to `writer` the public (HostIP:HostPort) of a given `containerPort/protocol` in a container. @@ -227,6 +235,10 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie return err } + if err := ReconfigIPCContainer(ctx, container, client, lab); err != nil { + return err + } + process, err := container.Spec(ctx) if err != nil { return err @@ -234,7 +246,10 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie flagT := process.Process.Terminal var con console.Console if flagA && flagT { - con = console.Current() + con, err = consoleutil.Current() + if err != nil { + return err + } defer con.Reset() if err := con.SetRaw(); err != nil { return err @@ -242,10 +257,10 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie } logURI := lab[labels.LogURI] - + namespace := lab[labels.Namespace] cStatus := formatter.ContainerStatus(ctx, container) if cStatus == "Up" { - logrus.Warnf("container %s is already running", container.ID()) + log.G(ctx).Warnf("container %s is already running", container.ID()) return nil } @@ -261,11 +276,17 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie } if oldTask, err := container.Task(ctx, nil); err == nil { if _, err := oldTask.Delete(ctx); err != nil { - logrus.WithError(err).Debug("failed to delete old task") + log.G(ctx).WithError(err).Debug("failed to delete old task") } } detachC := make(chan struct{}) - task, err := taskutil.NewTask(ctx, client, container, flagA, false, flagT, true, con, logURI, detachKeys, detachC) + attachStreamOpt := []string{} + if flagA { + // In start, flagA 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, false, flagT, true, con, logURI, detachKeys, namespace, detachC) if err != nil { return err } @@ -278,7 +299,7 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie } if flagA && flagT { if err := consoleutil.HandleConsoleResize(ctx, task, con); err != nil { - logrus.WithError(err).Error("console resize") + log.G(ctx).WithError(err).Error("console resize") } } sigc := signalutil.ForwardAllSignals(ctx, task) @@ -330,9 +351,19 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur if err != nil { return err } + ipc, err := ipcutil.DecodeIPCLabel(l[labels.IPC]) + if err != nil { + return err + } + // defer umount + defer func() { + if err := ipcutil.CleanUp(ipc); err != nil { + log.G(ctx).Warnf("failed to clean up IPC container %s: %s", container.ID(), err) + } + }() if timeout == nil { - t, ok := l[labels.StopTimout] + t, ok := l[labels.StopTimeout] if !ok { // Default is 10 seconds. t = "10" @@ -346,6 +377,13 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur task, err := container.Task(ctx, cio.Load) if err != nil { + // NOTE: NotFound doesn't mean that container hasn't started. + // In docker/CRI-containerd plugin, the task will be deleted + // when it exits. So, the status will be "created" for this + // case. + if errdefs.IsNotFound(err) { + return nil + } return err } @@ -389,7 +427,7 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur // signal will be sent once resume is finished if paused { if err := task.Resume(ctx); err != nil { - logrus.Warnf("Cannot unpause container %s: %s", container.ID(), err) + 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 @@ -421,7 +459,7 @@ func Stop(ctx context.Context, container containerd.Container, timeout *time.Dur // signal will be sent once resume is finished if paused { if err := task.Resume(ctx); err != nil { - logrus.Warnf("Cannot unpause container %s: %s", container.ID(), err) + log.G(ctx).Warnf("Cannot unpause container %s: %s", container.ID(), err) } } return waitContainerStop(ctx, exitCh, container.ID()) @@ -493,8 +531,112 @@ func Unpause(ctx context.Context, client *containerd.Client, id string) error { // ContainerStateDirPath returns the path to the Nerdctl-managed state directory for the container with the given ID. func ContainerStateDirPath(ns, dataStore, id string) (string, error) { - if err := nsutil.ValidateNamespaceName(ns); err != nil { - return "", fmt.Errorf("invalid namespace name %q for determining state dir of container %q: %s", ns, id, err) - } return filepath.Join(dataStore, "containers", ns, id), nil } + +// ContainerVolume is a struct representing a volume in a container. +type ContainerVolume struct { + Type string + Name string + Source string + Destination string + Mode string + RW bool + Propagation string +} + +// GetContainerVolumes is a function that returns a slice of containerVolume pointers. +// It accepts a map of container labels as input, where key is the label name and value is its associated value. +// The function iterates over the predefined volume labels (AnonymousVolumes and Mounts) +// and for each, it checks if the labels exists in the provided container labels. +// If yes, it decodes the label value from JSON format and appends the volumes to the result. +// In case of error during decoding, it logs the error and continues to the next label. +func GetContainerVolumes(containerLabels map[string]string) []*ContainerVolume { + var vols []*ContainerVolume + volLabels := []string{labels.AnonymousVolumes, labels.Mounts} + for _, volLabel := range volLabels { + names, ok := containerLabels[volLabel] + if !ok { + continue + } + var ( + volumes []*ContainerVolume + err error + ) + if volLabel == labels.Mounts { + err = json.Unmarshal([]byte(names), &volumes) + } + if volLabel == labels.AnonymousVolumes { + var anonymous []string + err = json.Unmarshal([]byte(names), &anonymous) + for _, anony := range anonymous { + volumes = append(volumes, &ContainerVolume{Name: anony}) + } + + } + if err != nil { + log.L.Warn(err) + } + vols = append(vols, volumes...) + } + return vols +} + +func GetContainerName(containerLabels map[string]string) string { + if name, ok := containerLabels[labels.Name]; ok { + return name + } + + if ns, ok := containerLabels[k8slabels.PodNamespace]; ok { + if podName, ok := containerLabels[k8slabels.PodName]; ok { + if containerName, ok := containerLabels[k8slabels.ContainerName]; ok { + // Container + return fmt.Sprintf("k8s://%s/%s/%s", ns, podName, containerName) + } + // Pod sandbox + return fmt.Sprintf("k8s://%s/%s", ns, podName) + } + } + return "" +} + +// EncodeContainerRmOptLabel encodes bool value for the --rm option into string value for a label. +func EncodeContainerRmOptLabel(rmOpt bool) string { + return fmt.Sprintf("%t", rmOpt) +} + +// DecodeContainerRmOptLabel decodes bool value for the --rm option from string value for a label. +func DecodeContainerRmOptLabel(rmOptLabel string) (bool, error) { + return strconv.ParseBool(rmOptLabel) +} + +// ParseExtraHosts takes an array of host-to-IP mapping strings, e.g. "localhost:127.0.0.1", +// and a hostGatewayIP for resolving mappings to "host-gateway". +// +// Returns a map of host-to-IPs or errors if any mapping strings are not correctly formatted. +func ParseExtraHosts(extraHosts []string, hostGatewayIP, separator string) ([]string, error) { + hosts := make([]string, 0, len(extraHosts)) + for _, hostToIP := range strutil.DedupeStrSlice(extraHosts) { + if _, err := dockercliopts.ValidateExtraHost(hostToIP); err != nil { + return nil, err + } + + parts := strings.SplitN(hostToIP, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid host-to-IP map %s", hostToIP) + } + + host, ip := parts[0], parts[1] + + // If the IP address is a string called "host-gateway", replace this value with the IP address stored + // in the daemon level HostGatewayIP config variable. + if ip == dockeropts.HostGatewayName && hostGatewayIP == "" { + return nil, errors.New("unable to derive the IP value for host-gateway") + } else if ip == dockeropts.HostGatewayName { + ip = hostGatewayIP + } + + hosts = append(hosts, host+separator+ip) + } + return hosts, nil +} diff --git a/pkg/containerutil/containerutil_test.go b/pkg/containerutil/containerutil_test.go new file mode 100644 index 00000000000..88d6c42be94 --- /dev/null +++ b/pkg/containerutil/containerutil_test.go @@ -0,0 +1,83 @@ +/* + 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 containerutil + +import ( + "reflect" + "testing" +) + +func TestParseExtraHosts(t *testing.T) { + tests := []struct { + name string + extraHosts []string + hostGateway string + separator string + expected []string + expectedErrStr string + }{ + { + name: "NoExtraHosts", + expected: []string{}, + }, + { + name: "ExtraHosts", + extraHosts: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + separator: ":", + expected: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + }, + { + name: "EqualsSeperator", + extraHosts: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + separator: "=", + expected: []string{"localhost=127.0.0.1", "localhost=[::1]"}, + }, + { + name: "InvalidExtraHostFormat", + extraHosts: []string{"localhost"}, + expectedErrStr: "bad format for add-host: \"localhost\"", + }, + { + name: "ErrorOnHostGatewayExtraHostWithNoHostGatewayIPSet", + extraHosts: []string{"localhost:host-gateway"}, + separator: ":", + expectedErrStr: "unable to derive the IP value for host-gateway", + }, + { + name: "HostGatewayIP", + extraHosts: []string{"localhost:host-gateway"}, + hostGateway: "10.10.0.1", + separator: ":", + expected: []string{"localhost:10.10.0.1"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + extraHosts, err := ParseExtraHosts(test.extraHosts, test.hostGateway, test.separator) + if err != nil && err.Error() != test.expectedErrStr { + t.Fatalf("expected '%s', actual '%v'", test.expectedErrStr, err) + } else if err == nil && test.expectedErrStr != "" { + t.Fatalf("expected error '%s' but got none", test.expectedErrStr) + } + + if !reflect.DeepEqual(test.expected, extraHosts) { + t.Fatalf("expected %v, actual %v", test.expected, extraHosts) + } + }) + } +} diff --git a/pkg/containerutil/cp_linux.go b/pkg/containerutil/cp_linux.go index 497ab474197..77425aa57be 100644 --- a/pkg/containerutil/cp_linux.go +++ b/pkg/containerutil/cp_linux.go @@ -17,7 +17,9 @@ package containerutil import ( + "bytes" "context" + "errors" "fmt" "os" "os/exec" @@ -25,78 +27,190 @@ import ( "strconv" "strings" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/tarutil" - securejoin "github.com/cyphar/filepath-securejoin" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/tarutil" ) -// CopyFiles implements `nerdctl cp`. -// // See https://docs.docker.com/engine/reference/commandline/cp/ for the specification. -func CopyFiles(ctx context.Context, container2host bool, pid int, dst, src string, followSymlink bool) error { - tarBinary, isGNUTar, err := tarutil.FindTarBinary() + +var ( + // Generic and system errors + ErrFilesystem = errors.New("filesystem error") // lstat hard errors, etc + ErrContainerVanished = errors.New("the container you are trying to copy to/from has been deleted") + ErrRootlessCannotCp = errors.New("cannot use cp with stopped containers in rootless mode") // rootless cp with a stopped container + ErrFailedMountingSnapshot = errors.New("failed mounting snapshot") // failure to mount a stopped container snapshot + + // CP specific errors + ErrTargetIsReadOnly = errors.New("cannot copy into read-only location") // ... + ErrSourceIsNotADir = errors.New("source is not a directory") // cp SOMEFILE/ foo:/ + ErrDestinationIsNotADir = errors.New("destination is not a directory") // * cp ./ foo:/etc/issue/bah + ErrSourceDoesNotExist = errors.New("source does not exist") // cp NONEXISTENT foo:/ + ErrDestinationParentMustExist = errors.New("destination parent does not exist") // nerdctl cp VALID_PATH foo:/NONEXISTENT/NONEXISTENT + ErrDestinationDirMustExist = errors.New("the destination directory must exist to be able to copy a file") // * cp SOMEFILE foo:/NONEXISTENT/ + ErrCannotCopyDirToFile = errors.New("cannot copy a directory to a file") // cp SOMEDIR foo:/etc/issue +) + +// getRoot will tentatively return the root of the container on the host (/proc/pid/root), along with the pid, +// (eg: doable when the container is running) +func getRoot(ctx context.Context, container containerd.Container) (string, int, error) { + task, err := container.Task(ctx, nil) if err != nil { - return err + return "", 0, err } - logrus.Debugf("Detected tar binary %q (GNU=%v)", tarBinary, isGNUTar) - var srcFull, dstFull string - root := fmt.Sprintf("/proc/%d/root", pid) - if container2host { - srcFull, err = securejoin.SecureJoin(root, src) - dstFull = dst - } else { - srcFull = src - dstFull, err = securejoin.SecureJoin(root, dst) + + status, err := task.Status(ctx) + if err != nil { + return "", 0, err } + + if status.Status != containerd.Running { + return "", 0, nil + } + pid := int(task.Pid()) + + return fmt.Sprintf("/proc/%d/root", pid), pid, nil +} + +// CopyFiles implements `nerdctl cp` +// It currently depends on the following assumptions: +// - linux only +// - tar binary exists on the system +// - nsenter binary exists on the system +// - if rootless, the container is running (aka: /proc/pid/root) +func CopyFiles(ctx context.Context, client *containerd.Client, container containerd.Container, options types.ContainerCpOptions) (err error) { + // We do rely on the tar binary as a shortcut - could also be replaced by archive/tar, though that would mean + // we need to replace nsenter calls with re-exec + tarBinary, isGNUTar, err := tarutil.FindTarBinary() if err != nil { return err } - var ( - srcIsDir bool - dstExists bool - dstExistsAsDir bool - ) - st, err := os.Stat(srcFull) + + log.G(ctx).Debugf("Detected tar binary %q (GNU=%v)", tarBinary, isGNUTar) + + // This can happen if the container being passed has been deleted since in a racy way + conSpec, err := container.Spec(ctx) if err != nil { - return err + return errors.Join(ErrContainerVanished, err) } - srcIsDir = st.IsDir() - // dst may not exist yet, so err is negligible - if st, err := os.Stat(dstFull); err == nil { - dstExists = true - dstExistsAsDir = st.IsDir() + // Try to get a running container root + root, pid, err := getRoot(ctx, container) + // If the task is "not found" (for example, if the container stopped), we will try to mount the snapshot + // Any other type of error from Task() is fatal here. + if err != nil && !errdefs.IsNotFound(err) { + return errors.Join(ErrContainerVanished, err) } - dstEndsWithSep := strings.HasSuffix(dst, string(os.PathSeparator)) - srcEndsWithSlashDot := strings.HasSuffix(src, string(os.PathSeparator)+".") - if !srcIsDir && dstEndsWithSep && !dstExistsAsDir { - // The error is specified in https://docs.docker.com/engine/reference/commandline/cp/ - // See the `DEST_PATH does not exist and ends with /` case. - return fmt.Errorf("the destination directory must exists: %w", err) + + log.G(ctx).Debugf("We have root %s and pid %d", root, pid) + + // If we have no root: + // - bail out for rootless + // - mount the snapshot for rootful + if root == "" { + // FIXME: Rootless does not support copying into/out of stopped/created containers as we need to nsenter into + // the user namespace of the pid of the running container with --preserve-credentials to preserve uid/gid + // mapping and copy files into the container. + if rootlessutil.IsRootless() { + return ErrRootlessCannotCp + } + + // See similar situation above. This may happen if we are racing against container deletion + var conInfo containers.Container + conInfo, err = container.Info(ctx) + if err != nil { + return errors.Join(ErrContainerVanished, err) + } + + var cleanup func() error + root, cleanup, err = mountSnapshotForContainer(ctx, client, conInfo, options.GOptions.Snapshotter) + if cleanup != nil { + defer func() { + err = errors.Join(err, cleanup()) + }() + } + + if err != nil { + return errors.Join(ErrFailedMountingSnapshot, err) + } + + log.G(ctx).Debugf("Got new root %s", root) } - if !srcIsDir && srcEndsWithSlashDot { - return fmt.Errorf("the source is not a directory") + + var sourceSpec, destinationSpec *pathSpecifier + var sourceErr, destErr error + if options.Container2Host { + sourceSpec, sourceErr = getPathSpecFromContainer(options.SrcPath, conSpec, root) + destinationSpec, destErr = getPathSpecFromHost(options.DestPath) + } else { + sourceSpec, sourceErr = getPathSpecFromHost(options.SrcPath) + destinationSpec, destErr = getPathSpecFromContainer(options.DestPath, conSpec, root) } - if srcIsDir && dstExists && !dstExistsAsDir { - return fmt.Errorf("cannot copy a directory to a file") + + if destErr != nil { + if errors.Is(destErr, errDoesNotExist) { + return ErrDestinationParentMustExist + } else if errors.Is(destErr, errIsNotADir) { + return ErrDestinationIsNotADir + } + + return errors.Join(ErrFilesystem, destErr) } - if srcIsDir && !dstExists { - if err := os.MkdirAll(dstFull, 0755); err != nil { - return err + + if sourceErr != nil { + if errors.Is(sourceErr, errDoesNotExist) { + return ErrSourceDoesNotExist + } else if errors.Is(sourceErr, errIsNotADir) { + return ErrSourceIsNotADir + } + + return errors.Join(ErrFilesystem, sourceErr) + } + + // Now, resolve cp shenanigans + // First, cannot copy a non-existent resource + if !sourceSpec.exists { + return ErrSourceDoesNotExist + } + + // Second, cannot copy into a readonly destination + if destinationSpec.readOnly { + return ErrTargetIsReadOnly + } + + // Cannot copy a dir into a file + if sourceSpec.isADir && destinationSpec.exists && !destinationSpec.isADir { + return ErrCannotCopyDirToFile + } + + // A file cannot be copied inside a non-existent directory with a trailing slash, or slash+dot + if !sourceSpec.isADir && !destinationSpec.exists && (destinationSpec.endsWithSeparator || destinationSpec.endsWithSeparatorDot) { + return ErrDestinationDirMustExist + } + + // XXX FIXME: this seems wrong. What about ownership? We could be doing that inside a container + if !destinationSpec.exists { + if err = os.Mkdir(destinationSpec.resolvedPath, 0o755); err != nil { + return errors.Join(ErrFilesystem, err) } } var tarCDir, tarCArg string - if srcIsDir { - if !dstExists || srcEndsWithSlashDot { + if sourceSpec.isADir { + if !destinationSpec.exists || sourceSpec.endsWithSeparatorDot { // the content of the source directory is copied into this directory - tarCDir = srcFull + tarCDir = sourceSpec.resolvedPath tarCArg = "." } else { // the source directory is copied into this directory - tarCDir = filepath.Dir(srcFull) - tarCArg = filepath.Base(srcFull) + tarCDir = filepath.Dir(sourceSpec.resolvedPath) + tarCArg = filepath.Base(sourceSpec.resolvedPath) } } else { // Prepare a single-file directory to create an archive of the source file @@ -107,46 +221,50 @@ func CopyFiles(ctx context.Context, container2host bool, pid int, dst, src strin defer os.RemoveAll(td) tarCDir = td cp := []string{"cp", "-a"} - if followSymlink { + if options.FollowSymLink { cp = append(cp, "-L") } - if dstEndsWithSep || dstExistsAsDir { - tarCArg = filepath.Base(srcFull) + if destinationSpec.endsWithSeparator || (destinationSpec.exists && destinationSpec.isADir) { + tarCArg = filepath.Base(sourceSpec.resolvedPath) } else { // Handle `nerdctl cp /path/to/file some-container:/path/to/file-with-another-name` - tarCArg = filepath.Base(dstFull) + tarCArg = filepath.Base(destinationSpec.resolvedPath) } - cp = append(cp, srcFull, filepath.Join(td, tarCArg)) + cp = append(cp, sourceSpec.resolvedPath, filepath.Join(td, tarCArg)) cpCmd := exec.CommandContext(ctx, cp[0], cp[1:]...) - logrus.Debugf("executing %v", cpCmd.Args) + log.G(ctx).Debugf("executing %v", cpCmd.Args) if out, err := cpCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to execute %v: %w (out=%q)", cpCmd.Args, err, string(out)) } } tarC := []string{tarBinary} - if followSymlink { + if options.FollowSymLink { tarC = append(tarC, "-h") } tarC = append(tarC, "-c", "-f", "-", tarCArg) - tarXDir := dstFull - if !srcIsDir && !dstEndsWithSep && !dstExistsAsDir { - tarXDir = filepath.Dir(dstFull) + tarXDir := destinationSpec.resolvedPath + if !sourceSpec.isADir && !destinationSpec.endsWithSeparator && !(destinationSpec.exists && destinationSpec.isADir) { + tarXDir = filepath.Dir(destinationSpec.resolvedPath) } tarX := []string{tarBinary, "-x"} - if container2host && isGNUTar { + if options.Container2Host && isGNUTar { tarX = append(tarX, "--no-same-owner") } tarX = append(tarX, "-f", "-") + if rootlessutil.IsRootless() { nsenter := []string{"nsenter", "-t", strconv.Itoa(pid), "-U", "--preserve-credentials", "--"} - if container2host { + if options.Container2Host { tarC = append(nsenter, tarC...) } else { tarX = append(nsenter, tarX...) } } + // FIXME: moving to archive/tar should allow better error management than this + // WARNING: some of our testing on stderr might not be portable across different versions of tar + // In these cases (readonly target), we will just get the straight tar output instead tarCCmd := exec.CommandContext(ctx, tarC[0], tarC[1:]...) tarCCmd.Dir = tarCDir tarCCmd.Stdin = nil @@ -159,21 +277,74 @@ func CopyFiles(ctx context.Context, container2host bool, pid int, dst, src strin return err } tarXCmd.Stdout = os.Stderr - tarXCmd.Stderr = os.Stderr + var tarErr bytes.Buffer + tarXCmd.Stderr = &tarErr - logrus.Debugf("executing %v in %q", tarCCmd.Args, tarCCmd.Dir) + log.G(ctx).Debugf("executing %v in %q", tarCCmd.Args, tarCCmd.Dir) if err := tarCCmd.Start(); err != nil { - return fmt.Errorf("failed to execute %v: %w", tarCCmd.Args, err) + return errors.Join(fmt.Errorf("failed to execute %v", tarCCmd.Args), err) } - logrus.Debugf("executing %v in %q", tarXCmd.Args, tarXCmd.Dir) + + log.G(ctx).Debugf("executing %v in %q", tarXCmd.Args, tarXCmd.Dir) if err := tarXCmd.Start(); err != nil { - return fmt.Errorf("failed to execute %v: %w", tarXCmd.Args, err) + if strings.Contains(err.Error(), "permission denied") { + return ErrTargetIsReadOnly + } + + // Other errors, just put them back on stderr + _, fpErr := fmt.Fprint(os.Stderr, tarErr.String()) + if fpErr != nil { + return errors.Join(fpErr, err) + } + + return errors.Join(fmt.Errorf("failed to execute %v", tarXCmd.Args), err) } + if err := tarCCmd.Wait(); err != nil { return fmt.Errorf("failed to wait %v: %w", tarCCmd.Args, err) } + if err := tarXCmd.Wait(); err != nil { - return fmt.Errorf("failed to wait %v: %w", tarXCmd.Args, err) + if strings.Contains(tarErr.String(), "Read-only file system") { + return ErrTargetIsReadOnly + } + + // Other errors, just put them back on stderr + _, fpErr := fmt.Fprint(os.Stderr, tarErr.String()) + if fpErr != nil { + return errors.Join(fpErr, err) + } + + return errors.Join(fmt.Errorf("failed to wait %v", tarXCmd.Args), err) } + return nil } + +func mountSnapshotForContainer(ctx context.Context, client *containerd.Client, conInfo containers.Container, snapshotter string) (string, func() error, error) { + snapKey := conInfo.SnapshotKey + resp, err := client.SnapshotService(snapshotter).Mounts(ctx, snapKey) + if err != nil { + return "", nil, err + } + + tempDir, err := os.MkdirTemp("", "nerdctl-cp-") + if err != nil { + return "", nil, err + } + + err = mount.All(resp, tempDir) + if err != nil { + return "", nil, err + } + + cleanup := func() error { + err = mount.Unmount(tempDir, 0) + if err != nil { + return err + } + return os.RemoveAll(tempDir) + } + + return tempDir, cleanup, nil +} diff --git a/pkg/containerutil/cp_resolve_linux.go b/pkg/containerutil/cp_resolve_linux.go new file mode 100644 index 00000000000..ab22abaf38e --- /dev/null +++ b/pkg/containerutil/cp_resolve_linux.go @@ -0,0 +1,444 @@ +/* + 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 containerutil + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "syscall" + + "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/containerd/containerd/v2/pkg/oci" +) + +// volumeNameLen returns length of the leading volume name on Windows. +// It returns 0 elsewhere. +// FIXME: whenever we will want to port cp to windows, we will need the windows implementation of volumeNameLen +func volumeNameLen(_ string) int { + return 0 +} + +var ( + errDoesNotExist = errors.New("resource does not exist") // when a path parent dir does not exist + errIsNotADir = errors.New("is not a dir") // when a path is a file, ending with path separator + errCannotResolvePathNoCwd = errors.New("unable to resolve path against undefined current working directory") // relative host path, no cwd +) + +// pathSpecifier represents a path to be used by cp +// besides exposing relevant properties (endsWithSeparator, etc), it also provides a fully resolved *host* path to +// access the resource +type pathSpecifier struct { + originalPath string + endsWithSeparator bool + endsWithSeparatorDot bool + exists bool + isADir bool + readOnly bool + resolvedPath string +} + +// getPathSpecFromHost builds a pathSpecifier from a host location +// errors with errDoesNotExist, errIsNotADir, "EvalSymlinks: too many links", or other hard filesystem errors from lstat/stat +func getPathSpecFromHost(originalPath string) (*pathSpecifier, error) { + pathSpec := &pathSpecifier{ + originalPath: originalPath, + endsWithSeparator: strings.HasSuffix(originalPath, string(os.PathSeparator)), + endsWithSeparatorDot: filepath.Base(originalPath) == ".", + } + + path := originalPath + + // Path may still be relative at this point. If it is, figure out getwd. + if !filepath.IsAbs(path) { + cwd, err := os.Getwd() + if err != nil { + return nil, errors.Join(errCannotResolvePathNoCwd, err) + } + path = cwd + string(os.PathSeparator) + path + } + + // Try to fully resolve the path + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + // Other errors: + // - "EvalSymlinks: too many links" + // - any other error coming from lstat + return nil, err + } + + pathSpec.exists = err == nil + + // Ensure the parent exists if the path itself does not + if !pathSpec.exists { + // Try the parent - obtain it by removing any trailing / or /., then the base + cleaned := strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + for len(cleaned) < len(path) { + path = cleaned + cleaned = strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + } + + base := filepath.Base(path) + path = strings.TrimSuffix(path, string(os.PathSeparator)+base) + + // Resolve it + resolvedPath, err = filepath.EvalSymlinks(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(errDoesNotExist, err) + } else if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + return nil, err + } + + resolvedPath = filepath.Join(resolvedPath, base) + } else { + // If it exists, we can check if it is a dir + var st os.FileInfo + st, err = os.Stat(path) + if err != nil { + return nil, err + } + pathSpec.isADir = st.IsDir() + } + + pathSpec.resolvedPath = resolvedPath + + return pathSpec, nil +} + +// getPathSpecFromHost builds a pathSpecifier from a container location +func getPathSpecFromContainer(originalPath string, conSpec *oci.Spec, containerHostRoot string) (*pathSpecifier, error) { + pathSpec := &pathSpecifier{ + originalPath: originalPath, + endsWithSeparator: strings.HasSuffix(originalPath, string(os.PathSeparator)), + endsWithSeparatorDot: filepath.Base(originalPath) == ".", + } + + path := originalPath + + // Path may still be relative at this point. If it is, join it to the root + // NOTE: this is specifically called out in the docker reference. Paths in the container are assumed + // relative to the root, and not to the current (container) working directory. + // Though this seems like a questionable decision, it is set. + if !filepath.IsAbs(path) { + path = string(os.PathSeparator) + path + } + + // Now, fully resolve the path - resolving all symlinks and cleaning-up the end result, following across mounts + pathResolver := newResolver(conSpec, containerHostRoot) + resolvedContainerPath, err := pathResolver.resolvePath(path) + + // Errors we get from that are from Lstat or Readlink + // Either the object does not exist, or we have a dangling symlink, or otherwise hosed filesystem entries + if err != nil && !errors.Is(err, os.ErrNotExist) { + if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + // errors.New("EvalSymlinks: too many links") + // other errors would come from lstat + return nil, err + } + + pathSpec.exists = err == nil + + // If the resource does not exist + if !pathSpec.exists { + // Try the parent + cleaned := strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + for len(cleaned) < len(path) { + path = cleaned + cleaned = strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + } + + base := filepath.Base(path) + path = strings.TrimSuffix(path, string(os.PathSeparator)+base) + + resolvedContainerPath, err = pathResolver.resolvePath(path) + + // Error? That is the end + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(errDoesNotExist, err) + } else if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + return nil, err + } + + resolvedContainerPath = filepath.Join(resolvedContainerPath, base) + } + + // Now, finally get the location of the fully resolved containerPath (in the root? in a volume?) + containerMount, relativePath := pathResolver.getMount(resolvedContainerPath) + pathSpec.resolvedPath = filepath.Join(containerMount.hostPath, relativePath) + // If the endpoint is readonly, flag it as such + if containerMount.readonly { + pathSpec.readOnly = true + } + + // If it exists, we can check if it is a dir + if pathSpec.exists { + var st os.FileInfo + st, err = os.Stat(pathSpec.resolvedPath) + if err != nil { + return nil, err + } + + pathSpec.isADir = st.IsDir() + } + + return pathSpec, nil +} + +// resolver provides methods to fully resolve any given container given path to a host location +// accounting for rootfs and mounts location +type resolver struct { + root *specs.Root + mounts []specs.Mount + hostRoot string +} + +// locator represents a container mount +type locator struct { + containerPath string + hostPath string + readonly bool +} + +func isParent(child []string, candidate []string) (bool, []string) { + if len(child) < len(candidate) { + return false, child + } + return slices.Equal(child[0:len(candidate)], candidate), child[len(candidate):] +} + +// newResolver returns a resolver struct +func newResolver(conSpec *oci.Spec, hostRoot string) *resolver { + return &resolver{ + root: conSpec.Root, + mounts: conSpec.Mounts, + hostRoot: hostRoot, + } +} + +// pathOnHost will return the *host* location of a container path, accounting for volumes. +// The provided path must be fully resolved, as returned by `resolvePath`. +func (res *resolver) pathOnHost(path string) string { + hostRoot := res.hostRoot + path = filepath.Clean(path) + itemized := strings.Split(path, string(os.PathSeparator)) + + containerRoot := "/" + sub := itemized + + for _, mnt := range res.mounts { + if candidateIsParent, subPath := isParent(itemized, strings.Split(mnt.Destination, string(os.PathSeparator))); candidateIsParent { + if len(mnt.Destination) > len(containerRoot) { + containerRoot = mnt.Destination + hostRoot = mnt.Source + sub = subPath + } + } + } + + return filepath.Join(append([]string{hostRoot}, sub...)...) +} + +// getMount returns the mount locator for a given fully-resolved path, along with the corresponding subpath of the path +// relative to the locator +func (res *resolver) getMount(path string) (*locator, string) { + itemized := strings.Split(path, string(os.PathSeparator)) + + loc := &locator{ + containerPath: "/", + hostPath: res.hostRoot, + readonly: res.root.Readonly, + } + + sub := itemized + + for _, mnt := range res.mounts { + if candidateIsParent, subPath := isParent(itemized, strings.Split(mnt.Destination, string(os.PathSeparator))); candidateIsParent { + if len(mnt.Destination) > len(loc.containerPath) { + loc.readonly = false + for _, option := range mnt.Options { + if option == "ro" { + loc.readonly = true + } + } + loc.containerPath = mnt.Destination + loc.hostPath = mnt.Source + sub = subPath + } + } + } + + return loc, filepath.Join(sub...) +} + +// resolvePath is adapted from https://cs.opensource.google/go/go/+/go1.23.0:src/path/filepath/path.go;l=147 +// The (only) changes are on Lstat and ReadLink, which are fed the actual host path, that is computed by `res.pathOnHost` +func (res *resolver) resolvePath(path string) (string, error) { + volLen := volumeNameLen(path) + pathSeparator := string(os.PathSeparator) + + if volLen < len(path) && os.IsPathSeparator(path[volLen]) { + volLen++ + } + vol := path[:volLen] + dest := vol + linksWalked := 0 + //nolint:ineffassign + for start, end := volLen, volLen; start < len(path); start = end { + for start < len(path) && os.IsPathSeparator(path[start]) { + start++ + } + end = start + for end < len(path) && !os.IsPathSeparator(path[end]) { + end++ + } + + // On Windows, "." can be a symlink. + // We look it up, and use the value if it is absolute. + // If not, we just return ".". + //nolint:staticcheck + isWindowsDot := runtime.GOOS == "windows" && path[volumeNameLen(path):] == "." + + // The next path component is in path[start:end]. + if end == start { + // No more path components. + break + } else if path[start:end] == "." && !isWindowsDot { + // Ignore path component ".". + continue + } else if path[start:end] == ".." { + // Back up to previous component if possible. + // Note that volLen includes any leading slash. + + // Set r to the index of the last slash in dest, + // after the volume. + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen || dest[r+1:] == ".." { + // Either path has no slashes + // (it's empty or just "C:") + // or it ends in a ".." we had to keep. + // Either way, keep this "..". + if len(dest) > volLen { + dest += pathSeparator + } + dest += ".." + } else { + // Discard everything since the last slash. + dest = dest[:r] + } + continue + } + + // Ordinary path component. Add it to result. + + if len(dest) > volumeNameLen(dest) && !os.IsPathSeparator(dest[len(dest)-1]) { + dest += pathSeparator + } + + dest += path[start:end] + + // Resolve symlink. + hostPath := res.pathOnHost(dest) + fi, err := os.Lstat(hostPath) + if err != nil { + return "", err + } + + if fi.Mode()&fs.ModeSymlink == 0 { + if !fi.Mode().IsDir() && end < len(path) { + return "", syscall.ENOTDIR + } + continue + } + + // Found symlink. + linksWalked++ + if linksWalked > 255 { + return "", errors.New("EvalSymlinks: too many links") + } + + link, err := os.Readlink(hostPath) + if err != nil { + return "", err + } + + if isWindowsDot && !filepath.IsAbs(link) { + // On Windows, if "." is a relative symlink, + // just return ".". + break + } + + path = link + path[end:] + + v := volumeNameLen(link) + if v > 0 { + // Symlink to drive name is an absolute path. + if v < len(link) && os.IsPathSeparator(link[v]) { + v++ + } + vol = link[:v] + dest = vol + end = len(vol) + } else if len(link) > 0 && os.IsPathSeparator(link[0]) { + // Symlink to absolute path. + dest = link[:1] + end = 1 + vol = link[:1] + volLen = 1 + } else { + // Symlink to relative path; replace last + // path component in dest. + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen { + dest = vol + } else { + dest = dest[:r] + } + end = 0 + } + } + return filepath.Clean(dest), nil +} diff --git a/pkg/containerutil/lock.go b/pkg/containerutil/lock.go new file mode 100644 index 00000000000..228b8790575 --- /dev/null +++ b/pkg/containerutil/lock.go @@ -0,0 +1,37 @@ +/* + 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 containerutil + +import ( + "path/filepath" + + "github.com/containerd/nerdctl/v2/pkg/store" +) + +func Lock(stateDir string) (store.Store, error) { + stor, err := store.New(filepath.Join(stateDir, "oplock"), 0, 0) + if err != nil { + return nil, err + } + + err = stor.Lock() + if err != nil { + return nil, err + } + + return stor, nil +} diff --git a/pkg/defaults/cgroup_linux.go b/pkg/defaults/cgroup_linux.go index caaae236e61..41e64cece16 100644 --- a/pkg/defaults/cgroup_linux.go +++ b/pkg/defaults/cgroup_linux.go @@ -20,7 +20,8 @@ import ( "os" "github.com/containerd/cgroups/v3" - "github.com/containerd/nerdctl/pkg/rootlessutil" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func IsSystemdAvailable() bool { diff --git a/pkg/defaults/defaults_darwin.go b/pkg/defaults/defaults_darwin.go new file mode 100644 index 00000000000..38db7823db1 --- /dev/null +++ b/pkg/defaults/defaults_darwin.go @@ -0,0 +1,45 @@ +/* + 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. +*/ + +// This is a dummy file to allow usage of library functions +// on Darwin-based systems. +// All functions and variables are empty/no-ops + +package defaults + +func CNIPath() string { + return "" +} + +func CNINetConfPath() string { + return "" +} + +func DataRoot() string { + return "" +} + +func CgroupManager() string { + return "" +} + +func HostsDirs() []string { + return []string{} +} + +func HostGatewayIP() string { + return "" +} diff --git a/pkg/defaults/defaults_freebsd.go b/pkg/defaults/defaults_freebsd.go index 620006a5e9a..8092beb8585 100644 --- a/pkg/defaults/defaults_freebsd.go +++ b/pkg/defaults/defaults_freebsd.go @@ -17,11 +17,14 @@ package defaults import ( - gocni "github.com/containerd/go-cni" + "github.com/containerd/go-cni" ) -const AppArmorProfileName = "" -const Runtime = "wtf.sbk.runj.v1" +const ( + AppArmorProfileName = "" + SeccompProfileName = "" + Runtime = "wtf.sbk.runj.v1" +) func DataRoot() string { return "/var/lib/nerdctl" @@ -29,21 +32,17 @@ func DataRoot() string { func CNIPath() string { // default: /opt/cni/bin - return gocni.DefaultCNIDir + return cni.DefaultCNIDir } func CNINetConfPath() string { - return gocni.DefaultNetDir + return cni.DefaultNetDir } func CNIRuntimeDir() string { return "/run/cni" } -func BuildKitHost() string { - return "unix:///run/buildkit/buildkitd.sock" -} - func CgroupManager() string { return "" } diff --git a/pkg/defaults/defaults_linux.go b/pkg/defaults/defaults_linux.go index b5a6a462f0a..ac35cf9c786 100644 --- a/pkg/defaults/defaults_linux.go +++ b/pkg/defaults/defaults_linux.go @@ -22,14 +22,18 @@ import ( "os" "path/filepath" - "github.com/containerd/containerd/plugin" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/plugins" + "github.com/containerd/go-cni" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) -const AppArmorProfileName = "nerdctl-default" -const Runtime = plugin.RuntimeRuncV2 +const ( + AppArmorProfileName = "nerdctl-default" + SeccompProfileName = "builtin" + Runtime = plugins.RuntimeRuncV2 +) func DataRoot() string { if !rootlessutil.IsRootless() { @@ -44,6 +48,7 @@ func DataRoot() string { func CNIPath() string { candidates := []string{ + cni.DefaultCNIDir, // /opt/cni/bin "/usr/local/libexec/cni", "/usr/local/lib/cni", "/usr/libexec/cni", // Fedora @@ -56,9 +61,9 @@ func CNIPath() string { } candidates = append([]string{ // NOTE: These user paths are not defined in XDG + filepath.Join(home, "opt/cni/bin"), filepath.Join(home, ".local/libexec/cni"), filepath.Join(home, ".local/lib/cni"), - filepath.Join(home, "opt/cni/bin"), }, candidates...) } @@ -69,12 +74,12 @@ func CNIPath() string { } // default: /opt/cni/bin - return gocni.DefaultCNIDir + return cni.DefaultCNIDir } func CNINetConfPath() string { if !rootlessutil.IsRootless() { - return gocni.DefaultNetDir + return cni.DefaultNetDir } xch, err := rootlessutil.XDGConfigHome() if err != nil { @@ -89,24 +94,12 @@ func CNIRuntimeDir() string { } xdr, err := rootlessutil.XDGRuntimeDir() if err != nil { - logrus.Warn(err) + log.L.Warn(err) xdr = fmt.Sprintf("/run/user/%d", rootlessutil.ParentEUID()) } return fmt.Sprintf("%s/cni", xdr) } -func BuildKitHost() string { - if !rootlessutil.IsRootless() { - return "unix:///run/buildkit/buildkitd.sock" - } - xdr, err := rootlessutil.XDGRuntimeDir() - if err != nil { - logrus.Warn(err) - xdr = fmt.Sprintf("/run/user/%d", rootlessutil.ParentEUID()) - } - return fmt.Sprintf("unix://%s/buildkit/buildkitd.sock", xdr) -} - func NerdctlTOML() string { if !rootlessutil.IsRootless() { return "/etc/nerdctl/nerdctl.toml" @@ -134,6 +127,7 @@ func HostsDirs() []string { // HostGatewayIP returns the non-loop-back host ip if available and returns empty string if running into error. func HostGatewayIP() string { + // no need to use [rootlessutil.WithDetachedNetNSIfAny] here addrs, err := net.InterfaceAddrs() if err != nil { return "" diff --git a/pkg/defaults/defaults_windows.go b/pkg/defaults/defaults_windows.go index 7c4a7929dfe..65d74d2c8bb 100644 --- a/pkg/defaults/defaults_windows.go +++ b/pkg/defaults/defaults_windows.go @@ -17,13 +17,15 @@ package defaults import ( - "fmt" "os" "path/filepath" ) -const AppArmorProfileName = "" -const Runtime = "io.containerd.runhcs.v1" +const ( + AppArmorProfileName = "" + SeccompProfileName = "" + Runtime = "io.containerd.runhcs.v1" +) func DataRoot() string { return filepath.Join(os.Getenv("ProgramData"), "nerdctl") @@ -41,10 +43,6 @@ func CNIRuntimeDir() string { return "" } -func BuildKitHost() string { - return fmt.Sprint("\\\\.\\pipe\\buildkit") -} - func IsSystemdAvailable() bool { return false } diff --git a/pkg/dnsutil/dnsutil.go b/pkg/dnsutil/dnsutil.go index acbfc68c7c4..433a19b324b 100644 --- a/pkg/dnsutil/dnsutil.go +++ b/pkg/dnsutil/dnsutil.go @@ -19,7 +19,7 @@ package dnsutil import ( "context" - "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func GetSlirp4netnsDNS() ([]string, error) { diff --git a/pkg/dnsutil/hostsstore/hostsstore.go b/pkg/dnsutil/hostsstore/hostsstore.go index f90e7ad8752..1980e4f934b 100644 --- a/pkg/dnsutil/hostsstore/hostsstore.go +++ b/pkg/dnsutil/hostsstore/hostsstore.go @@ -14,91 +14,69 @@ limitations under the License. */ -// Package hostsstore provides the interface for /var/lib/nerdctl//etchosts . -// Prioritize simplicity over scalability. +// Package hostsstore provides the interface for /var/lib/nerdctl//etchosts +// Prioritizes simplicity over scalability. +// All methods perform atomic writes and are safe to use concurrently. +// Note that locking is done per namespace. +// hostsstore is currently by container rename, remove, network managers, and ocihooks +// Finally, NOTE: +// Since we will write to the hosts file after it is mounted in the container, we cannot use our atomic write method +// as the inode would change on rename. +// Henceforth, hosts file mutation uses filesystem methods instead, making it the one exception that has to bypass +// the Store implementation. package hostsstore import ( + "bytes" "encoding/json" "errors" + "fmt" "os" "path/filepath" + "strings" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/lockutil" types100 "github.com/containernetworking/cni/pkg/types/100" + + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/store" ) const ( // hostsDirBasename is the base name of /var/lib/nerdctl//etchosts hostsDirBasename = "etchosts" - // metaJSON is stored as /var/lib/nerdctl//etchosts///meta.json + // metaJSON is stored as hostsDirBasename///meta.json metaJSON = "meta.json" + // hostsFile is stored as hostsDirBasename///hosts + hostsFile = "hosts" ) -// HostsPath returns "/var/lib/nerdctl//etchosts///hosts" -func HostsPath(dataStore, ns, id string) string { - if dataStore == "" || ns == "" || id == "" { - panic(errdefs.ErrInvalidArgument) - } - return filepath.Join(dataStore, hostsDirBasename, ns, id, "hosts") -} +// ErrHostsStore will wrap all errors here +var ErrHostsStore = errors.New("hosts-store error") -// ensureFile ensures a file with permission 0644. -// The file is initialized with no content. -// The dir (if not exists) is created with permission 0700. -func ensureFile(path string) error { - if path == "" { - return errdefs.ErrInvalidArgument - } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE, 0644) - if err != nil { - f.Close() - } - return err -} +func New(dataStore string, namespace string) (retStore Store, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() -// AllocHostsFile is used for creating mount-bindable /etc/hosts file. -// The file is initialized with no content. -func AllocHostsFile(dataStore, ns, id string) (string, error) { - lockDir := filepath.Join(dataStore, hostsDirBasename) - if err := os.MkdirAll(lockDir, 0700); err != nil { - return "", err + if dataStore == "" || namespace == "" { + return nil, store.ErrInvalidArgument } - path := HostsPath(dataStore, ns, id) - fn := func() error { - return ensureFile(path) - } - err := lockutil.WithDirLock(lockDir, fn) - return path, err -} -func DeallocHostsFile(dataStore, ns, id string) error { - lockDir := filepath.Join(dataStore, hostsDirBasename) - if err := os.MkdirAll(lockDir, 0700); err != nil { - return err - } - dirToBeRemoved := filepath.Dir(HostsPath(dataStore, ns, id)) - fn := func() error { - return os.RemoveAll(dirToBeRemoved) + st, err := store.New(filepath.Join(dataStore, hostsDirBasename, namespace), 0, 0o644) + if err != nil { + return nil, err } - return lockutil.WithDirLock(lockDir, fn) -} -func NewStore(dataStore string) (Store, error) { - store := &store{ - dataStore: dataStore, - hostsD: filepath.Join(dataStore, hostsDirBasename), - } - return store, os.MkdirAll(store.hostsD, 0700) + return &hostsStore{ + safeStore: st, + }, nil } type Meta struct { - Namespace string ID string Networks map[string]*types100.Result Hostname string @@ -108,76 +86,270 @@ type Meta struct { type Store interface { Acquire(Meta) error - Release(ns, id string) error - Update(ns, id, newName string) error + Release(id string) error + Update(id, newName string) error + HostsPath(id string) (location string, err error) + Delete(id string) (err error) + AllocHostsFile(id string, content []byte) (location string, err error) } -type store struct { - // dataStore is /var/lib/nerdctl/ - dataStore string - // hostsD is /var/lib/nerdctl//etchosts - hostsD string +type hostsStore struct { + safeStore store.Store } -func (x *store) Acquire(meta Meta) error { - fn := func() error { - hostsPath := HostsPath(x.dataStore, meta.Namespace, meta.ID) - if err := ensureFile(hostsPath); err != nil { +func (x *hostsStore) Acquire(meta Meta) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() + + return x.safeStore.WithLock(func() error { + var loc string + loc, err = x.safeStore.Location(meta.ID, hostsFile) + if err != nil { return err } - metaB, err := json.Marshal(meta) + + if err = os.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 + // 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. + if err = os.Chmod(loc, 0o644); err != nil { + err = errors.Join(store.ErrSystemFailure, err) + } + + var content []byte + content, err = json.Marshal(meta) if err != nil { return err } - metaPath := filepath.Join(x.hostsD, meta.Namespace, meta.ID, metaJSON) - if err := os.WriteFile(metaPath, metaB, 0644); err != nil { + + if err = x.safeStore.Set(content, meta.ID, metaJSON); err != nil { return err } - return newUpdater(meta.ID, x.hostsD, meta.ExtraHosts).update() - } - return lockutil.WithDirLock(x.hostsD, fn) + + return x.updateAllHosts() + }) } // Release is triggered by Poststop hooks. // It is called after the containerd task is deleted but before the delete operation returns. -func (x *store) Release(ns, id string) error { - fn := func() error { - metaPath := filepath.Join(x.hostsD, ns, id, metaJSON) - if _, err := os.Stat(metaPath); errors.Is(err, os.ErrNotExist) { - return nil - } - // We remove "meta.json" but we still retain the "hosts" file - // because it is needed for restarting. The "hosts" is removed on - // `nerdctl rm`. - // https://github.com/rootless-containers/rootlesskit/issues/220#issuecomment-783224610 - if err := os.RemoveAll(metaPath); err != nil { +func (x *hostsStore) Release(id string) (err error) { + // We remove "meta.json" but we still retain the "hosts" file + // because it is needed for restarting. The "hosts" is removed on + // `nerdctl rm`. + // https://github.com/rootless-containers/rootlesskit/issues/220#issuecomment-783224610 + defer func() { + if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() + + return x.safeStore.WithLock(func() error { + if err = x.safeStore.Delete(id, metaJSON); err != nil { + return err + } + + return x.updateAllHosts() + }) +} + +// AllocHostsFile is used for creating mount-bindable /etc/hosts file. +func (x *hostsStore) AllocHostsFile(id string, content []byte) (location string, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() + + err = x.safeStore.WithLock(func() error { + err = x.safeStore.GroupEnsure(id) + if err != nil { + return err + } + + var loc string + loc, err = x.safeStore.Location(id, hostsFile) + if err != nil { return err } - return newUpdater(id, x.hostsD, nil).update() + + err = os.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 + // 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. + if err = os.Chmod(loc, 0o644); err != nil { + err = errors.Join(store.ErrSystemFailure, err) + } + + return err + }) + if err != nil { + return "", err + } + + return x.safeStore.Location(id, hostsFile) +} + +func (x *hostsStore) Delete(id string) (err error) { + err = x.safeStore.WithLock(func() error { return x.safeStore.Delete(id) }) + if err != nil { + err = errors.Join(ErrHostsStore, err) } - return lockutil.WithDirLock(x.hostsD, fn) + + return err +} + +func (x *hostsStore) HostsPath(id string) (location string, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() + + return x.safeStore.Location(id, hostsFile) } -func (x *store) Update(ns, id, newName string) error { - fn := func() error { - metaPath := filepath.Join(x.hostsD, ns, id, metaJSON) - metaB, err := os.ReadFile(metaPath) +func (x *hostsStore) Update(id, newName string) (err error) { + defer func() { if err != nil { + err = errors.Join(ErrHostsStore, err) + } + }() + + return x.safeStore.WithLock(func() error { + var content []byte + if content, err = x.safeStore.Get(id, metaJSON); err != nil { return err } + meta := &Meta{} - if err := json.Unmarshal(metaB, meta); err != nil { + if err = json.Unmarshal(content, meta); err != nil { return err } + meta.Name = newName - metaB, err = json.Marshal(meta) + content, err = json.Marshal(meta) if err != nil { return err } - if err := os.WriteFile(metaPath, metaB, 0644); err != nil { + + if err = x.safeStore.Set(content, id, metaJSON); err != nil { return err } - return newUpdater(meta.ID, x.hostsD, meta.ExtraHosts).update() + + return x.updateAllHosts() + }) +} + +func (x *hostsStore) updateAllHosts() (err error) { + entries, err := x.safeStore.List() + if err != nil { + return err + } + + metasByEntry := map[string]*Meta{} + metasByIP := map[string]*Meta{} + networkNameByIP := map[string]string{} + + // Phase 1: read all meta files + for _, entry := range entries { + var content []byte + content, err = x.safeStore.Get(entry, metaJSON) + if err != nil { + log.L.WithError(err).Debugf("unable to read %q", entry) + continue + } + meta := &Meta{} + if err = json.Unmarshal(content, meta); err != nil { + log.L.WithError(err).Warnf("unable to unmarshell %q", entry) + continue + } + metasByEntry[entry] = meta + + for netName, cniRes := range meta.Networks { + for _, ipCfg := range cniRes.IPs { + if ip := ipCfg.Address.IP; ip != nil { + if ip.IsLoopback() || ip.IsUnspecified() { + continue + } + ipStr := ip.String() + metasByIP[ipStr] = meta + networkNameByIP[ipStr] = netName + } + } + } + } + + // Phase 2: write hosts files + for _, entry := range entries { + myMeta, ok := metasByEntry[entry] + if !ok { + log.L.WithError(errdefs.ErrNotFound).Debugf("hostsstore metadata %q not found in %q?", metaJSON, entry) + continue + } + + myNetworks := make(map[string]struct{}) + for nwName := range myMeta.Networks { + myNetworks[nwName] = struct{}{} + } + + var content []byte + content, err = x.safeStore.Get(entry, hostsFile) + if err != nil { + log.L.WithError(err).Errorf("unable to retrieve the hosts file for %q", entry) + continue + } + + // parse the hosts file, keep the original host record + // retain custom /etc/hosts entries outside region + var buf bytes.Buffer + if content != nil { + if err = parseHostsButSkipMarkedRegion(&buf, bytes.NewReader(content)); err != nil { + log.L.WithError(err).Errorf("failed to read hosts file for %q", entry) + continue + } + } + + buf.WriteString(fmt.Sprintf("# %s\n", MarkerBegin)) + buf.WriteString("127.0.0.1 localhost localhost.localdomain\n") + buf.WriteString("::1 localhost localhost.localdomain\n") + + // keep extra hosts first + for host, ip := range myMeta.ExtraHosts { + buf.WriteString(fmt.Sprintf("%-15s %s\n", ip, host)) + } + + for ip, netName := range networkNameByIP { + meta := metasByIP[ip] + if line := createLine(netName, meta, myNetworks); len(line) != 0 { + buf.WriteString(fmt.Sprintf("%-15s %s\n", ip, strings.Join(line, " "))) + } + } + + buf.WriteString(fmt.Sprintf("# %s\n", MarkerEnd)) + + var loc string + loc, err = x.safeStore.Location(entry, hostsFile) + if err != nil { + return err + } + + err = os.WriteFile(loc, buf.Bytes(), 0o644) + if err != nil { + log.L.WithError(err).Errorf("failed to write hosts file for %q", entry) + } + _ = os.Chmod(loc, 0o644) } - return lockutil.WithDirLock(x.hostsD, fn) + return nil } diff --git a/pkg/dnsutil/hostsstore/updater.go b/pkg/dnsutil/hostsstore/updater.go index 51dd9768939..2b77bb8301d 100644 --- a/pkg/dnsutil/hostsstore/updater.go +++ b/pkg/dnsutil/hostsstore/updater.go @@ -17,157 +17,9 @@ package hostsstore import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/containerd/containerd/errdefs" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/netutil" ) -// newUpdater creates an updater for hostsD (/var/lib/nerdctl//etchosts) -func newUpdater(id, hostsD string, extraHosts map[string]string) *updater { - u := &updater{ - id: id, - hostsD: hostsD, - metaByIPStr: make(map[string]*Meta), - nwNameByIPStr: make(map[string]string), - metaByDir: make(map[string]*Meta), - extraHosts: extraHosts, - } - return u -} - -// updater is the struct for updater.update() -type updater struct { - id string - hostsD string // "/var/lib/nerdctl//etchosts" - metaByIPStr map[string]*Meta // key: IP string - nwNameByIPStr map[string]string // key: IP string, value: key of Meta.Networks - metaByDir map[string]*Meta // key: "/var/lib/nerdctl//etchosts//" - extraHosts map[string]string // key: host value: IP string -} - -// update updates the hostsD tree. -// Must be called with a locker for the hostsD directory. -func (u *updater) update() error { - // phase1: read meta.json - if err := u.phase1(); err != nil { - return err - } - // phase2: write hosts - if err := u.phase2(); err != nil { - return err - } - return nil -} - -// phase1: read meta.json -func (u *updater) phase1() error { - readMetaWF := func(path string, _ os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if filepath.Base(path) != metaJSON { - return nil - } - metaB, err := os.ReadFile(path) - if err != nil { - return err - } - var meta Meta - if err := json.Unmarshal(metaB, &meta); err != nil { - return err - } - u.metaByDir[filepath.Dir(path)] = &meta - for nwName, cniRes := range meta.Networks { - for _, ipCfg := range cniRes.IPs { - if ip := ipCfg.Address.IP; ip != nil { - if ip.IsLoopback() || ip.IsUnspecified() { - continue - } - ipStr := ip.String() - u.metaByIPStr[ipStr] = &meta - u.nwNameByIPStr[ipStr] = nwName - } - } - } - return nil - } - if err := filepath.Walk(u.hostsD, readMetaWF); err != nil { - return err - } - return nil -} - -// phase2: write hosts -func (u *updater) phase2() error { - writeHostsWF := func(path string, _ os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if filepath.Base(path) != "hosts" { - return nil - } - dir := filepath.Dir(path) - myMeta, ok := u.metaByDir[dir] - if !ok { - logrus.WithError(errdefs.ErrNotFound).Debugf("hostsstore metadata %q not found in %q?", metaJSON, dir) - return nil - } - myNetworks := make(map[string]struct{}) - for nwName := range myMeta.Networks { - myNetworks[nwName] = struct{}{} - } - - // parse the hosts file, keep the original host record - // retain custom /etc/hosts entries outside region - r, err := os.Open(path) - if err != nil { - return err - } - var buf bytes.Buffer - if r != nil { - if err := parseHostsButSkipMarkedRegion(&buf, r); err != nil { - logrus.WithError(err).Warn("failed to read hosts file") - } - } - - buf.WriteString(fmt.Sprintf("# %s\n", MarkerBegin)) - buf.WriteString("127.0.0.1 localhost localhost.localdomain\n") - buf.WriteString("::1 localhost localhost.localdomain\n") - - // keep extra hosts first - if u.id == myMeta.ID { - for host, ip := range u.extraHosts { - buf.WriteString(fmt.Sprintf("%-15s %s\n", ip, host)) - } - } - - for ip, nwName := range u.nwNameByIPStr { - meta := u.metaByIPStr[ip] - if line := createLine(nwName, meta, myNetworks); len(line) != 0 { - buf.WriteString(fmt.Sprintf("%-15s %s\n", ip, strings.Join(line, " "))) - } - } - - buf.WriteString(fmt.Sprintf("# %s\n", MarkerEnd)) - err = os.WriteFile(path, buf.Bytes(), 0644) - if err != nil { - return err - } - return nil - } - if err := filepath.Walk(u.hostsD, writeHostsWF); err != nil { - return err - } - return nil -} - // createLine returns a line string slice. // line is like "foo foo.nw0 bar bar.nw0\n" // for `nerdctl --name=foo --hostname=bar --network=n0`. diff --git a/pkg/dnsutil/hostsstore/updater_test.go b/pkg/dnsutil/hostsstore/updater_test.go index c55f4ae3cd6..03ffefae12b 100644 --- a/pkg/dnsutil/hostsstore/updater_test.go +++ b/pkg/dnsutil/hostsstore/updater_test.go @@ -74,8 +74,7 @@ func TestCreateLine(t *testing.T) { } for _, tc := range testCases { thatMeta := &Meta{ - Namespace: "default", - ID: "984d63ce45ae", + ID: "984d63ce45ae", Networks: map[string]*types100.Result{ tc.thatNetwork: { Interfaces: []*types100.Interface{ diff --git a/pkg/errutil/errors_check.go b/pkg/errutil/errors_check.go index 755fbf2582e..202c4fe8518 100644 --- a/pkg/errutil/errors_check.go +++ b/pkg/errutil/errors_check.go @@ -18,15 +18,6 @@ package errutil import "strings" -// IsErrHTTPResponseToHTTPSClient returns whether err is -// "http: server gave HTTP response to HTTPS client" -func IsErrHTTPResponseToHTTPSClient(err error) bool { - // The error string is unexposed as of Go 1.16, so we can't use `errors.Is`. - // https://github.com/golang/go/issues/44855 - const unexposed = "server gave HTTP response to HTTPS client" - return strings.Contains(err.Error(), unexposed) -} - // IsErrConnectionRefused return whether err is // "connect: connection refused" func IsErrConnectionRefused(err error) bool { diff --git a/pkg/eventutil/eventutil.go b/pkg/eventutil/eventutil.go index fa4c7d8afe2..a624332e80d 100644 --- a/pkg/eventutil/eventutil.go +++ b/pkg/eventutil/eventutil.go @@ -19,7 +19,7 @@ package eventutil import ( "sync" - "github.com/containerd/containerd/events" + "github.com/containerd/containerd/v2/core/events" ) type eventHandler struct { diff --git a/pkg/flagutil/flagutil.go b/pkg/flagutil/flagutil.go index 064b8a8ab1a..663a5746161 100644 --- a/pkg/flagutil/flagutil.go +++ b/pkg/flagutil/flagutil.go @@ -23,7 +23,7 @@ import ( "os" "strings" - "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // ReplaceOrAppendEnvValues returns the defaults with the overrides either diff --git a/pkg/formatter/common.go b/pkg/formatter/common.go index 8915f0dc3ec..18cd6e6ca45 100644 --- a/pkg/formatter/common.go +++ b/pkg/formatter/common.go @@ -42,11 +42,16 @@ func FormatSlice(format string, writer io.Writer, x []interface{}) error { var tmpl *template.Template switch format { case "": - b, err := json.MarshalIndent(x, "", " ") + // Avoid escaping "<", ">", "&" + // https://pkg.go.dev/encoding/json + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + err := encoder.Encode(x) if err != nil { return err } - fmt.Fprintln(writer, string(b)) + fmt.Fprint(writer, "\n") case "raw", "table", "wide": return errors.New("unsupported format: \"raw\", \"table\", and \"wide\"") default: @@ -65,7 +70,7 @@ func FormatSlice(format string, writer io.Writer, x []interface{}) error { } } } - if _, err = fmt.Fprintf(writer, b.String()+"\n"); err != nil { + if _, err = fmt.Fprintln(writer, b.String()); err != nil { return err } } diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index d0dc761a5c9..1c6acdcfced 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -25,16 +25,17 @@ import ( "strings" "time" + "github.com/docker/go-units" "golang.org/x/text/cases" "golang.org/x/text/language" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/runtime/restart" - "github.com/containerd/nerdctl/pkg/portutil" - "github.com/docker/go-units" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "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" ) func ContainerStatus(ctx context.Context, c containerd.Container) string { @@ -118,7 +119,7 @@ func Ellipsis(str string, maxDisplayWidth int) string { func FormatPorts(labelMap map[string]string) string { ports, err := portutil.ParsePortsLabel(labelMap) if err != nil { - logrus.Error(err.Error()) + log.L.Error(err.Error()) } if len(ports) == 0 { return "" diff --git a/pkg/identifiers/validate.go b/pkg/identifiers/validate.go new file mode 100644 index 00000000000..7f5ff3522d3 --- /dev/null +++ b/pkg/identifiers/validate.go @@ -0,0 +1,46 @@ +/* + 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 identifiers implements functions for docker compatible identifier validation. +package identifiers + +import ( + "fmt" + "regexp" + + "github.com/containerd/errdefs" +) + +const AllowedIdentfierChars = `[a-zA-Z0-9][a-zA-Z0-9_.-]` + +var AllowedIdentifierPattern = regexp.MustCompile(`^` + AllowedIdentfierChars + `+$`) + +// ValidateDockerCompat implements docker compatible identifier validation. +// The containerd implementation allows single character identifiers, while the +// Docker compatible implementation requires at least 2 characters for identifiers. +// The containerd implementation enforces a maximum length constraint of 76 characters, +// while the Docker compatible implementation omits the length check entirely. +func ValidateDockerCompat(s string) error { + if len(s) == 0 { + return fmt.Errorf("identifier must not be empty %w", errdefs.ErrInvalidArgument) + } + + if !AllowedIdentifierPattern.MatchString(s) { + return fmt.Errorf("identifier %q must match pattern %q: %w", s, AllowedIdentfierChars, errdefs.ErrInvalidArgument) + } + + return nil +} diff --git a/pkg/idutil/containerwalker/containerwalker.go b/pkg/idutil/containerwalker/containerwalker.go index d90896e894b..83e9926046f 100644 --- a/pkg/idutil/containerwalker/containerwalker.go +++ b/pkg/idutil/containerwalker/containerwalker.go @@ -22,8 +22,9 @@ import ( "regexp" "strings" - "github.com/containerd/containerd" - "github.com/containerd/nerdctl/pkg/labels" + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/labels" ) type Found struct { diff --git a/pkg/idutil/imagewalker/imagewalker.go b/pkg/idutil/imagewalker/imagewalker.go index a67f9e58051..c99cdb8b432 100644 --- a/pkg/idutil/imagewalker/imagewalker.go +++ b/pkg/idutil/imagewalker/imagewalker.go @@ -22,25 +22,47 @@ import ( "regexp" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/nerdctl/pkg/referenceutil" "github.com/opencontainers/go-digest" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) type Found struct { - Image images.Image - Req string // The raw request string. name, short ID, or long ID. - MatchIndex int // Begins with 0, up to MatchCount - 1. - MatchCount int // 1 on exact match. > 1 on ambiguous match. Never be <= 0. - UniqueImages int // Number of unique images in all found images. + Image images.Image + Req string // The raw request string. name, short ID, or long ID. + MatchIndex int // Begins with 0, up to MatchCount - 1. + MatchCount int // 1 on exact match. > 1 on ambiguous match. Never be <= 0. + UniqueImages int // Number of unique images in all found images. + NameMatchIndex int // Image index with a name matching the argument for `nerdctl rmi`. } type OnFound func(ctx context.Context, found Found) error +/* +In order to resolve the issue with OnFoundCriRm, the same imageId under +k8s.io is showing multiple results: repo:tag, repo:digest, configID. We expect +to display only repo:tag, consistent with other namespaces and CRI. +e.g. + + nerdctl -n k8s.io images + REPOSITORY TAG IMAGE ID CREATED PLATFORM SIZE BLOB SIZE + centos 7 be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + centos be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + be65f488b776 3 hours ago linux/amd64 211.5 MiB 72.6 MiB + +The boolean value will return true only when the repo:tag is successfully +deleted for each image. Once all repo:tag entries are deleted, it is necessary +to clean up the remaining repo:digest and configID. +*/ +type OnFoundCriRm func(ctx context.Context, found Found) (bool, error) + type ImageWalker struct { - Client *containerd.Client - OnFound OnFound + Client *containerd.Client + OnFound OnFound + OnFoundCriRm OnFoundCriRm } // Walk walks images and calls w.OnFound . @@ -48,8 +70,12 @@ type ImageWalker struct { // Returns the number of the found entries. func (w *ImageWalker) Walk(ctx context.Context, req string) (int, error) { var filters []string - if canonicalRef, err := referenceutil.ParseAny(req); err == nil { - filters = append(filters, fmt.Sprintf("name==%s", canonicalRef.String())) + var parsedReferenceStr string + + parsedReference, err := referenceutil.Parse(req) + if err == nil { + parsedReferenceStr = parsedReference.String() + filters = append(filters, fmt.Sprintf("name==%s", parsedReferenceStr)) } filters = append(filters, fmt.Sprintf("name==%s", req), @@ -66,17 +92,23 @@ func (w *ImageWalker) Walk(ctx context.Context, req string) (int, error) { // to handle the `rmi -f` case where returned images are different but // have the same short prefix. uniqueImages := make(map[digest.Digest]bool) - for _, image := range images { + nameMatchIndex := -1 + for i, image := range images { uniqueImages[image.Target.Digest] = true + // to get target image index for `nerdctl rmi `. + if (parsedReferenceStr != "" && image.Name == parsedReferenceStr) || image.Name == req { + nameMatchIndex = i + } } for i, img := range images { f := Found{ - Image: img, - Req: req, - MatchIndex: i, - MatchCount: matchCount, - UniqueImages: len(uniqueImages), + Image: img, + Req: req, + MatchIndex: i, + MatchCount: matchCount, + UniqueImages: len(uniqueImages), + NameMatchIndex: nameMatchIndex, } if e := w.OnFound(ctx, f); e != nil { return -1, e @@ -85,6 +117,103 @@ func (w *ImageWalker) Walk(ctx context.Context, req string) (int, error) { return matchCount, nil } +// WalkCriRm walks images and calls w.OnFoundCriRm . +// Only effective when in the k8s.io namespace and kube-hide-dupe is enabled. +// The WalkCriRm deletes non-repo:tag items such as repo:digest when in the no-other-repo:tag scenario. +func (w *ImageWalker) WalkCriRm(ctx context.Context, req string) (int, error) { + var filters []string + var parsedReferenceStr, repo string + var imageTag, imagesRepo []images.Image + var tagNum int + + parsedReference, err := referenceutil.Parse(req) + if err == nil { + parsedReferenceStr = parsedReference.String() + filters = append(filters, fmt.Sprintf("name==%s", parsedReferenceStr)) + } + //Get the image ID , if reg == imageTag use + image, err := w.Client.GetImage(ctx, parsedReferenceStr) + if err != nil { + repo = req + } else { + repo = strings.Split(image.Target().Digest.String(), ":")[1][:12] + } + + filters = append(filters, + fmt.Sprintf("name==%s", req), + fmt.Sprintf("target.digest~=^sha256:%s.*$", regexp.QuoteMeta(repo)), + fmt.Sprintf("target.digest~=^%s.*$", regexp.QuoteMeta(repo)), + ) + + images, err := w.Client.ImageService().List(ctx, filters...) + if err != nil { + return -1, err + } + + // to handle the `rmi -f` case where returned images are different but + // have the same short prefix. + uniqueImages := make(map[digest.Digest]bool) + nameMatchIndex := -1 + + //Distinguish between tag and non-tag + for _, img := range images { + ref := img.Name + parsed, err := referenceutil.Parse(ref) + if err != nil { + continue + } + if parsed.Tag != "" { + imageTag = append(imageTag, img) + tagNum++ + uniqueImages[img.Target.Digest] = true + // to get target image index for `nerdctl rmi `. + if (parsedReferenceStr != "" && img.Name == parsedReferenceStr) || img.Name == req { + nameMatchIndex = len(imageTag) - 1 + } + } else { + imagesRepo = append(imagesRepo, img) + } + } + + matchCount := len(imageTag) + if matchCount < 1 && len(imagesRepo) > 0 { + matchCount = 1 + } + + for i, img := range imageTag { + f := Found{ + Image: img, + Req: req, + MatchIndex: i, + MatchCount: matchCount, + UniqueImages: len(uniqueImages), + NameMatchIndex: nameMatchIndex, + } + if ok, e := w.OnFoundCriRm(ctx, f); e != nil { + return -1, e + } else if ok { + tagNum = tagNum - 1 + } + } + //If the corresponding imageTag does not exist, delete the repoDigests + if tagNum == 0 { + for i, img := range imagesRepo { + f := Found{ + Image: img, + Req: req, + MatchIndex: i, + MatchCount: 1, + UniqueImages: 1, + NameMatchIndex: -1, + } + if _, e := w.OnFoundCriRm(ctx, f); e != nil { + return -1, e + } + } + } + return matchCount, nil +} + // WalkAll calls `Walk` for each req in `reqs`. // // It can be used when the matchCount is not important (e.g., only care if there diff --git a/pkg/idutil/netwalker/netwalker.go b/pkg/idutil/netwalker/netwalker.go deleted file mode 100644 index cd92d4eaa89..00000000000 --- a/pkg/idutil/netwalker/netwalker.go +++ /dev/null @@ -1,119 +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 netwalker - -import ( - "context" - "fmt" - "regexp" - "strings" - - "github.com/containerd/nerdctl/pkg/netutil" -) - -type Found struct { - Network *netutil.NetworkConfig - Req string // The raw request string. name, short ID, or long ID. - MatchIndex int // Begins with 0, up to MatchCount - 1. - MatchCount int // 1 on exact match. > 1 on ambiguous match. Never be <= 0. -} - -type OnFound func(ctx context.Context, found Found) error - -type NetworkWalker struct { - Client *netutil.CNIEnv - OnFound OnFound -} - -// Walk walks networks and calls w.OnFound . -// Req is name, short ID, or long ID. -// Returns the number of the found entries. -func (w *NetworkWalker) Walk(ctx context.Context, req string) (int, error) { - longIDExp, err := regexp.Compile(fmt.Sprintf("^sha256:%s.*", regexp.QuoteMeta(req))) - if err != nil { - return 0, err - } - - shortIDExp, err := regexp.Compile(fmt.Sprintf("^%s", regexp.QuoteMeta(req))) - if err != nil { - return 0, err - } - - idFilterF := func(n *netutil.NetworkConfig) bool { - if n.NerdctlID == nil { - // External network - return n.Name == req - } - return n.Name == req || longIDExp.Match([]byte(*n.NerdctlID)) || shortIDExp.Match([]byte(*n.NerdctlID)) - } - networks, err := w.Client.FilterNetworks(idFilterF) - if err != nil { - return 0, err - } - - matchCount := len(networks) - - for i, network := range networks { - f := Found{ - Network: network, - Req: req, - MatchIndex: i, - MatchCount: matchCount, - } - if e := w.OnFound(ctx, f); e != nil { - return -1, e - } - } - return matchCount, nil -} - -// WalkAll calls `Walk` for each req in `reqs`. -// -// It can be used when the matchCount is not important (e.g., only care if there -// is any error or if matchCount == 0 (not found error) when walking all reqs). -// If `forceAll`, it calls `Walk` on every req -// and return all errors joined by `\n`. If not `forceAll`, it returns the first error -// encountered while calling `Walk`. -// `allowSeudoNetwork` allows seudo network (host, none) to be passed to `Walk`, otherwise -// an error is recorded for it. -func (w *NetworkWalker) WalkAll(ctx context.Context, reqs []string, forceAll, allowSeudoNetwork bool) error { - var errs []string - for _, req := range reqs { - if !allowSeudoNetwork && (req == "host" || req == "none") { - err := fmt.Errorf("pseudo network not allowed: %s", req) - if !forceAll { - return err - } - errs = append(errs, err.Error()) - } else { - n, err := w.Walk(ctx, req) - if err == nil && n == 0 { - err = fmt.Errorf("no such network: %s", req) - } - if err != nil { - if !forceAll { - return err - } - errs = append(errs, err.Error()) - } - } - } - if len(errs) > 0 { - return fmt.Errorf("%d errors:\n%s", len(errs), strings.Join(errs, "\n")) - } - return nil -} diff --git a/pkg/imageinspector/imageinspector.go b/pkg/imageinspector/imageinspector.go index 6d98826fb77..ce1f0003f05 100644 --- a/pkg/imageinspector/imageinspector.go +++ b/pkg/imageinspector/imageinspector.go @@ -19,22 +19,24 @@ package imageinspector import ( "context" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - imgutil "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" ) // Inspect inspects the image, for the platform specified in image.platform. -func Inspect(ctx context.Context, client *containerd.Client, image images.Image, snapshotter string) (*native.Image, error) { +func Inspect(ctx context.Context, client *containerd.Client, image images.Image, snapshotter snapshots.Snapshotter) (*native.Image, error) { n := &native.Image{} img := containerd.NewImage(client, image) idx, idxDesc, err := imgutil.ReadIndex(ctx, img) if err != nil { - logrus.WithError(err).WithField("id", image.Name).Warnf("failed to inspect index") + log.G(ctx).WithError(err).WithField("id", image.Name).Warnf("failed to inspect index") } else { n.IndexDesc = idxDesc n.Index = idx @@ -42,7 +44,7 @@ func Inspect(ctx context.Context, client *containerd.Client, image images.Image, mani, maniDesc, err := imgutil.ReadManifest(ctx, img) if err != nil { - logrus.WithError(err).WithField("id", image.Name).Warnf("failed to inspect manifest") + log.G(ctx).WithError(err).WithField("id", image.Name).Warnf("failed to inspect manifest") } else { n.ManifestDesc = maniDesc n.Manifest = mani @@ -50,15 +52,14 @@ func Inspect(ctx context.Context, client *containerd.Client, image images.Image, imageConfig, imageConfigDesc, err := imgutil.ReadImageConfig(ctx, img) if err != nil { - logrus.WithError(err).WithField("id", image.Name).Warnf("failed to inspect image config") + log.G(ctx).WithError(err).WithField("id", image.Name).Warnf("failed to inspect image config") } else { n.ImageConfigDesc = imageConfigDesc n.ImageConfig = imageConfig } - snapSvc := client.SnapshotService(snapshotter) - n.Size, err = imgutil.UnpackedImageSize(ctx, snapSvc, img) + n.Size, err = imgutil.UnpackedImageSize(ctx, snapshotter, img) if err != nil { - logrus.WithError(err).WithField("id", image.Name).Warnf("failed to inspect calculate size") + log.G(ctx).WithError(err).WithField("id", image.Name).Warnf("failed to inspect calculate size") } n.Image = image diff --git a/pkg/imgutil/commit/commit.go b/pkg/imgutil/commit/commit.go index a45fb939621..101cf7987f5 100644 --- a/pkg/imgutil/commit/commit.go +++ b/pkg/imgutil/commit/commit.go @@ -27,24 +27,29 @@ import ( "strings" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/diff" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/leases" - "github.com/containerd/containerd/platforms" - "github.com/containerd/containerd/rootfs" - "github.com/containerd/containerd/snapshots" - imgutil "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/labels" "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/identity" "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/diff" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/leases" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/pkg/cio" + "github.com/containerd/containerd/v2/pkg/rootfs" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + imgutil "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/labels" ) type Changes struct { @@ -64,7 +69,34 @@ var ( emptyDigest = digest.Digest("") ) -func Commit(ctx context.Context, client *containerd.Client, container containerd.Container, opts *Opts) (digest.Digest, error) { +func Commit(ctx context.Context, client *containerd.Client, container containerd.Container, opts *Opts, globalOptions types.GlobalCommandOptions) (digest.Digest, error) { + // Get labels + containerLabels, err := container.Labels(ctx) + if err != nil { + return emptyDigest, err + } + + // Get datastore + dataStore, err := clientutil.DataStore(globalOptions.DataRoot, globalOptions.Address) + if err != nil { + return emptyDigest, err + } + + // Ensure we do have a stateDir label + stateDir := containerLabels[labels.StateDir] + if stateDir == "" { + stateDir, err = containerutil.ContainerStateDirPath(globalOptions.Namespace, dataStore, container.ID()) + if err != nil { + return emptyDigest, err + } + } + + lf, err := containerutil.Lock(stateDir) + if err != nil { + return emptyDigest, err + } + defer lf.Release() + id := container.ID() info, err := container.Info(ctx) if err != nil { @@ -80,13 +112,13 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd platformLabel := info.Labels[labels.Platform] if platformLabel == "" { platformLabel = platforms.DefaultString() - logrus.Warnf("Image lacks label %q, assuming the platform to be %q", labels.Platform, platformLabel) + log.G(ctx).Warnf("Image lacks label %q, assuming the platform to be %q", labels.Platform, platformLabel) } ocispecPlatform, err := platforms.Parse(platformLabel) if err != nil { return emptyDigest, err } - logrus.Debugf("ocispecPlatform=%q", platforms.Format(ocispecPlatform)) + log.G(ctx).Debugf("ocispecPlatform=%q", platforms.Format(ocispecPlatform)) platformMC := platforms.Only(ocispecPlatform) baseImg := containerd.NewImageWithPlatform(client, baseImgWithoutPlatform, platformMC) @@ -95,12 +127,19 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd return emptyDigest, err } - task, err := container.Task(ctx, cio.Load) + // Ensure all the layers are here: https://github.com/containerd/nerdctl/issues/3425 + err = image.EnsureAllContent(ctx, client, baseImg.Name(), globalOptions) if err != nil { - return emptyDigest, err + 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.") } if opts.Pause { + task, err := container.Task(ctx, cio.Load) + if err != nil { + return emptyDigest, err + } + status, err := task.Status(ctx) if err != nil { return emptyDigest, err @@ -115,7 +154,7 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd defer func() { if err := task.Resume(ctx); err != nil { - logrus.Warnf("failed to unpause container %v: %v", id, err) + log.G(ctx).Warnf("failed to unpause container %v: %v", id, err) } }() } @@ -170,6 +209,13 @@ func Commit(ctx context.Context, client *containerd.Client, container containerd return emptyDigest, fmt.Errorf("failed to create new image %s: %w", opts.Ref, err) } } + + // unpack the image to snapshotter + cimg := containerd.NewImage(client, img) + if err := cimg.Unpack(ctx, snName); err != nil { + return emptyDigest, err + } + return configDigest, nil } @@ -205,14 +251,14 @@ func generateCommitImageConfig(ctx context.Context, container containerd.Contain arch := baseConfig.Architecture if arch == "" { arch = runtime.GOARCH - logrus.Warnf("assuming arch=%q", arch) + log.G(ctx).Warnf("assuming arch=%q", arch) } os := baseConfig.OS if os == "" { os = runtime.GOOS - logrus.Warnf("assuming os=%q", os) + log.G(ctx).Warnf("assuming os=%q", os) } - logrus.Debugf("generateCommitImageConfig(): arch=%q, os=%q", arch, os) + log.G(ctx).Debugf("generateCommitImageConfig(): arch=%q, os=%q", arch, os) return ocispec.Image{ Platform: ocispec.Platform{ Architecture: arch, @@ -352,7 +398,7 @@ func applyDiffLayer(ctx context.Context, name string, baseImg ocispec.Image, sn // NOTE: the snapshotter should be hold by lease. Even // if the cleanup fails, the containerd gc can delete it. if err := sn.Remove(ctx, key); err != nil { - logrus.Warnf("failed to cleanup aborted apply %s: %s", key, err) + log.G(ctx).Warnf("failed to cleanup aborted apply %s: %s", key, err) } } }() diff --git a/pkg/imgutil/converter/zstd.go b/pkg/imgutil/converter/zstd.go new file mode 100644 index 00000000000..bb177a63442 --- /dev/null +++ b/pkg/imgutil/converter/zstd.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 converter + +import ( + "context" + "fmt" + "io" + + "github.com/klauspost/compress/zstd" + 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/containerd/v2/core/images/converter" + "github.com/containerd/containerd/v2/core/images/converter/uncompress" + "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/errdefs" + + "github.com/containerd/nerdctl/v2/pkg/api/types" +) + +// ZstdLayerConvertFunc converts legacy tar.gz layers into zstd layers with +// the specified compression level. +func ZstdLayerConvertFunc(options types.ImageConvertOptions) (converter.ConvertFunc, error) { + return func(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) { + if !images.IsLayerType(desc.MediaType) { + // No conversion. No need to return an error here. + return nil, nil + } + var err error + // Read it + readerAt, err := cs.ReaderAt(ctx, desc) + if err != nil { + return nil, err + } + defer readerAt.Close() + sectionReader := io.NewSectionReader(readerAt, 0, desc.Size) + + info, err := cs.Info(ctx, desc.Digest) + if err != nil { + return nil, err + } + + var oldReader io.Reader + // If it is compressed, get a decompressed stream + if !uncompress.IsUncompressedType(desc.MediaType) { + decompStream, err := compression.DecompressStream(sectionReader) + if err != nil { + return nil, err + } + defer decompStream.Close() + oldReader = decompStream + } else { + oldReader = sectionReader + } + + ref := fmt.Sprintf("convert-zstd-from-%s", desc.Digest) + w, err := content.OpenWriter(ctx, cs, content.WithRef(ref)) + if err != nil { + return nil, err + } + defer w.Close() + + // Reset the writing position + // Old writer possibly remains without aborted + // (e.g. conversion interrupted by a signal) + if err := w.Truncate(0); err != nil { + return nil, err + } + + pr, pw := io.Pipe() + enc, err := zstd.NewWriter(pw, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(options.ZstdCompressionLevel))) + if err != nil { + return nil, err + } + go func() { + if _, err := io.Copy(enc, oldReader); err != nil { + pr.CloseWithError(err) + return + } + if err = enc.Close(); err != nil { + pr.CloseWithError(err) + return + } + if err = pw.Close(); err != nil { + pr.CloseWithError(err) + return + } + }() + + n, err := io.Copy(w, pr) + if err != nil { + return nil, err + } + + if err = w.Commit(ctx, 0, "", content.WithLabels(info.Labels)); err != nil && !errdefs.IsAlreadyExists(err) { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + newDesc := desc + newDesc.Digest = w.Digest() + newDesc.Size = n + newDesc.MediaType = ocispec.MediaTypeImageLayerZstd + return &newDesc, nil + }, nil +} diff --git a/pkg/imgutil/dockerconfigresolver/credentialsstore.go b/pkg/imgutil/dockerconfigresolver/credentialsstore.go new file mode 100644 index 00000000000..b08deba271c --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/credentialsstore.go @@ -0,0 +1,174 @@ +/* + 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 dockerconfigresolver + +import ( + "errors" + "fmt" + "strings" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/config/types" +) + +type Credentials = types.AuthConfig + +// NewCredentialsStore returns a CredentialsStore from a directory +// If path is left empty, the default docker `~/.docker/config.json` will be used +// In case the docker call fails, we wrap the error with ErrUnableToInstantiate +func NewCredentialsStore(path string) (*CredentialsStore, error) { + dockerConfigFile, err := config.Load(path) + if err != nil { + return nil, errors.Join(ErrUnableToInstantiate, err) + } + + return &CredentialsStore{ + dockerConfigFile: dockerConfigFile, + }, nil +} + +// CredentialsStore is an abstraction in front of docker config API manipulation +// exposing just the limited functions we need and hiding away url normalization / identifiers magic, and handling of +// backward compatibility +type CredentialsStore struct { + dockerConfigFile *configfile.ConfigFile +} + +// Erase will remove any and all stored credentials for that registry namespace (including all legacy variants) +// If we do not find at least ONE variant matching the namespace, this will error with ErrUnableToErase +func (cs *CredentialsStore) Erase(registryURL *RegistryURL) (map[string]error, error) { + // Get all associated identifiers for that registry including legacy ones and variants + logoutList := registryURL.AllIdentifiers() + + // Iterate through and delete them one by one + errs := make(map[string]error) + for _, serverAddress := range logoutList { + if err := cs.dockerConfigFile.GetCredentialsStore(serverAddress).Erase(serverAddress); err != nil { + errs[serverAddress] = err + } + } + + // If we succeeded removing at least one, it is a success. + // The only error condition is if we failed removing anything - meaning there was no such credential information + // in whatever format - or the store is broken. + if len(errs) == len(logoutList) { + return errs, ErrUnableToErase + } + + return nil, nil +} + +// Store will save credentials for a given registry +// On error, ErrUnableToStore +func (cs *CredentialsStore) Store(registryURL *RegistryURL, credentials *Credentials) error { + // We just overwrite the server property here with the host + // Whether it was one of the variants, or was not set at all (see for example Amazon ECR, https://github.com/containerd/nerdctl/issues/733 + // - which is likely a bug in docker) it doesn't matter. + // This is the credentials that were returned for that host, by the docker credentials store. + if registryURL.Namespace != nil { + credentials.ServerAddress = fmt.Sprintf("%s%s?%s", registryURL.Host, registryURL.Path, registryURL.RawQuery) + } else { + credentials.ServerAddress = registryURL.CanonicalIdentifier() + } + + // XXX future namespaced url likely require special handling here + if err := cs.dockerConfigFile.GetCredentialsStore(registryURL.CanonicalIdentifier()).Store(*(credentials)); err != nil { + return errors.Join(ErrUnableToStore, err) + } + + return nil +} + +// ShellCompletion will return candidate strings for nerdctl logout +func (cs *CredentialsStore) ShellCompletion() []string { + candidates := []string{} + for key := range cs.dockerConfigFile.AuthConfigs { + candidates = append(candidates, key) + } + + return candidates +} + +// FileStorageLocation will return the file where credentials are stored for a given registry, or the empty string +// if it is stored / to be stored in a different place (like an OS keychain, with docker credential helpers) +func (cs *CredentialsStore) FileStorageLocation(registryURL *RegistryURL) string { + if store, isFile := (cs.dockerConfigFile.GetCredentialsStore(registryURL.CanonicalIdentifier())).(isFileStore); isFile { + return store.GetFilename() + } + + return "" +} + +// Retrieve gets existing credentials from the store for a certain registry. +// If none are found, an empty Credentials struct is returned. +// If we hard-fail reading from the store, indicative of a broken system, we wrap the error with ErrUnableToRetrieve +func (cs *CredentialsStore) Retrieve(registryURL *RegistryURL, checkCredStore bool) (*Credentials, error) { + var err error + returnedCredentials := &Credentials{} + + // As long as we depend on .ServerAddress, make sure it is populated correctly + // It does not matter what was stored - the docker cli clearly has issues with this + // What matters is that the credentials retrieved from the docker credentials store are *for that registryURL* + // and that is what ServerAddress should point to + defer func() { + if registryURL.Namespace != nil { + returnedCredentials.ServerAddress = fmt.Sprintf("%s%s?%s", registryURL.Host, registryURL.Path, registryURL.RawQuery) + } else { + returnedCredentials.ServerAddress = registryURL.Host + } + }() + + if !checkCredStore { + return returnedCredentials, nil + } + + // Get the legacy variants (w/o scheme or port), and iterate over until we find one with credentials + variants := registryURL.AllIdentifiers() + + for _, identifier := range variants { + var credentials types.AuthConfig + // Note that Get does not raise an error on ENOENT + credentials, err = cs.dockerConfigFile.GetCredentialsStore(identifier).Get(identifier) + if err != nil { + continue + } + returnedCredentials = &credentials + // Clean-up the username + returnedCredentials.Username = strings.TrimSpace(returnedCredentials.Username) + // Stop here if we found credentials with this variant + if returnedCredentials.IdentityToken != "" || + returnedCredentials.Username != "" || + returnedCredentials.Password != "" || + returnedCredentials.RegistryToken != "" { + break + } + } + + // (Last non nil) credential store error gets wrapped into ErrUnableToRetrieve + if err != nil { + err = errors.Join(ErrUnableToRetrieve, err) + } + + return returnedCredentials, err +} + +// isFileStore is an internal mock interface purely meant to help identify that the docker credential backend is a filesystem one +type isFileStore interface { + IsFileStore() bool + GetFilename() string +} diff --git a/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go b/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go new file mode 100644 index 00000000000..003fe1aa211 --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/credentialsstore_test.go @@ -0,0 +1,401 @@ +/* + 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 dockerconfigresolver + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "gotest.tools/v3/assert" +) + +func createTempDir(t *testing.T, mode os.FileMode) string { + tmpDir, err := os.MkdirTemp(t.TempDir(), "docker-config") + if err != nil { + t.Fatal(err) + } + err = os.Chmod(tmpDir, mode) + if err != nil { + t.Fatal(err) + } + return tmpDir +} + +func TestBrokenCredentialsStore(t *testing.T) { + if runtime.GOOS == "freebsd" { + // It is unclear why these tests are failing on FreeBSD, and if it is a problem with Vagrant or differences + // with FreeBSD + // Anyhow, this test is about extreme cases & conditions (filesystem errors wrt credentials loading). + t.Skip("skipping broken credential store tests for freebsd") + } + if runtime.GOOS == "windows" { + // Same as above + t.Skip("test is not compatible with windows") + } + + testCases := []struct { + description string + setup func() string + errorNew error + errorRead error + errorWrite error + }{ + { + description: "Pointing DOCKER_CONFIG at a non-existent directory inside an unreadable directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0000) + return filepath.Join(tmpDir, "doesnotexistcantcreate") + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a non-existent directory inside a read-only directory will prevent saving credentials", + setup: func() string { + tmpDir := createTempDir(t, 0500) + return filepath.Join(tmpDir, "doesnotexistcantcreate") + }, + errorWrite: ErrUnableToStore, + }, + { + description: "Pointing DOCKER_CONFIG at an unreadable directory will prevent instantiation", + setup: func() string { + return createTempDir(t, 0000) + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a read-only directory will prevent saving credentials", + setup: func() string { + return createTempDir(t, 0500) + }, + errorWrite: ErrUnableToStore, + }, + { + 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) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a file instead of a directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + fd, err := os.OpenFile(filepath.Join(tmpDir, "isafile"), os.O_CREATE, 0600) + if err != nil { + t.Fatal(err) + } + err = fd.Close() + if err != nil { + t.Fatal(err) + } + return filepath.Join(tmpDir, "isafile") + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing a `config.json` directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.Mkdir(filepath.Join(tmpDir, "config.json"), 0600) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing a `config.json` dangling symlink will still work", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.Symlink("doesnotexist", filepath.Join(tmpDir, "config.json")) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + }, + { + 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) + if err != nil { + t.Fatal(err) + } + err = os.Chmod(filepath.Join(tmpDir, "config.json"), 0000) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + 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) + if err != nil { + t.Fatal(err) + } + err = os.Chmod(filepath.Join(tmpDir, "config.json"), 0400) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + }, + } + + t.Run("Docker Config testing with a variety of filesystem situations", func(t *testing.T) { + // Do NOT parallelize this test, as it relies on Chdir, which would have side effects for other tests. + registryURL, err := Parse("registry") + if err != nil { + t.Fatal(err) + } + + for _, testCase := range testCases { + tc := testCase + t.Run(tc.description, func(tt *testing.T) { + // See https://github.com/containerd/nerdctl/issues/3413 + var oldpwd string + directory := tc.setup() + oldpwd, err = os.Getwd() + assert.NilError(tt, err) + // Ignore the error, as the destination may not be a directory + _ = os.Chdir(directory) + tt.Cleanup(func() { + err = os.Chdir(oldpwd) + assert.NilError(tt, err) + }) + + var cs *CredentialsStore + cs, err = NewCredentialsStore(directory) + assert.ErrorIs(tt, err, tc.errorNew) + if err != nil { + return + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(tt, err, tc.errorRead) + + err = cs.Store(registryURL, af) + assert.ErrorIs(tt, err, tc.errorWrite) + }) + } + }) +} + +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) + if err != nil { + t.Fatal(err) + } + return tmpDir +} + +func TestWorkingCredentialsStore(t *testing.T) { + testCases := []struct { + description string + setup func() string + username string + password string + }{ + { + description: "Reading credentials from `auth` using canonical identifier", + username: "username", + password: "password", + setup: func() string { + content := fmt.Sprintf(`{ + "auths": { + "registry.example:443": { + "auth": %q + } + } + }`, base64.StdEncoding.EncodeToString([]byte("username:password"))) + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: registry.example", + username: "username", + setup: func() string { + content := `{ + "auths": { + "registry.example": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: http://registry.example", + username: "username", + setup: func() string { + content := `{ + "auths": { + "http://registry.example": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: https://registry.example", + username: "username", + setup: func() string { + content := `{ + "auths": { + "https://registry.example": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: http://registry.example:443", + username: "username", + setup: func() string { + content := `{ + "auths": { + "http://registry.example:443": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: https://registry.example:443", + username: "username", + setup: func() string { + content := `{ + "auths": { + "https://registry.example:443": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Canonical form is preferred over legacy forms", + username: "pick", + setup: func() string { + content := `{ + "auths": { + "http://registry.example:443": { + "username": "ignore" + }, + "https://registry.example:443": { + "username": "ignore" + }, + "registry.example": { + "username": "ignore" + }, + "registry.example:443": { + "serveraddress": "bla", + "username": "pick" + }, + "http://registry.example": { + "username": "ignore" + }, + "https://registry.example": { + "username": "ignore" + } + } +}` + return writeContent(t, content) + }, + }, + } + + t.Run("Working credentials store", func(t *testing.T) { + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + registryURL, err := Parse("registry.example") + if err != nil { + t.Fatal(err) + } + cs, err := NewCredentialsStore(tc.setup()) + if err != nil { + t.Fatal(err) + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(t, err, nil) + assert.Equal(t, af.Username, tc.username) + assert.Equal(t, af.ServerAddress, "registry.example:443") + assert.Equal(t, af.Password, tc.password) + }) + } + }) + + t.Run("Namespaced host", func(t *testing.T) { + server := "host.example/path?ns=namespace.example" + registryURL, err := Parse(server) + if err != nil { + t.Fatal(err) + } + + content := `{ + "auths": { + "nerdctl-experimental://namespace.example:443/host/host.example:443/path": { + "username": "username" + } + } + }` + dir := writeContent(t, content) + cs, err := NewCredentialsStore(dir) + if err != nil { + t.Fatal(err) + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(t, err, nil) + assert.Equal(t, af.Username, "username") + assert.Equal(t, af.ServerAddress, "host.example:443/path?ns=namespace.example") + + }) +} + +// TODO: add more tests that write credentials (specifically to hub locations) to verify they use the canonical id properly diff --git a/pkg/imgutil/dockerconfigresolver/defaults.go b/pkg/imgutil/dockerconfigresolver/defaults.go new file mode 100644 index 00000000000..ea91279e3a9 --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/defaults.go @@ -0,0 +1,52 @@ +/* + 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 dockerconfigresolver + +import "errors" + +type scheme string + +const ( + StandardHTTPSPort = "443" + + schemeHTTPS scheme = "https" + schemeHTTP scheme = "http" + // schemeNerdctlExperimental is currently provisional, to unlock namespace based host authentication + // This may change or break without notice, and you should have no expectations that credentials saved like that + // will be supported in the future + schemeNerdctlExperimental scheme = "nerdctl-experimental" + // See https://github.com/moby/moby/blob/v27.1.1/registry/config.go#L42-L48 + //nolint:misspell + // especially Sebastiaan comments on future domain consolidation + dockerIndexServer = "https://index.docker.io/v1/" + // The query parameter that containerd will slap on namespaced hosts + namespaceQueryParameter = "ns" +) + +// Errors returned by the credentials store +var ( + ErrUnableToInstantiate = errors.New("unable to instantiate docker credentials store") + ErrUnableToErase = errors.New("unable to erase credentials") + ErrUnableToStore = errors.New("unable to store credentials") + ErrUnableToRetrieve = errors.New("unable to retrieve credentials") +) + +// Errors returned by `Parse` +var ( + ErrUnparsableURL = errors.New("unparsable registry URL") + ErrUnsupportedScheme = errors.New("unsupported scheme in registry URL") +) diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go index db9bd7b265f..8577b8e2bc6 100644 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go +++ b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go @@ -20,17 +20,12 @@ import ( "context" "crypto/tls" "errors" - "fmt" - "os" - - "github.com/containerd/containerd/remotes" - "github.com/containerd/containerd/remotes/docker" - dockerconfig "github.com/containerd/containerd/remotes/docker/config" - dockercliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/config/credentials" - dockercliconfigtypes "github.com/docker/cli/cli/config/types" - "github.com/docker/docker/errdefs" - "github.com/sirupsen/logrus" + + "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/errdefs" + "github.com/containerd/log" ) var PushTracker = docker.NewInMemoryTracker() @@ -61,24 +56,9 @@ func WithSkipVerifyCerts(b bool) Opt { // WithHostsDirs specifies directories like /etc/containerd/certs.d and /etc/docker/certs.d func WithHostsDirs(orig []string) Opt { - var ss []string - if len(orig) == 0 { - logrus.Debug("no hosts dir was specified") - } - for _, v := range orig { - if _, err := os.Stat(v); err == nil { - logrus.Debugf("Found hosts dir %q", v) - ss = append(ss, v) - } else { - if errors.Is(err, os.ErrNotExist) { - logrus.WithError(err).Debugf("Ignoring hosts dir %q", v) - } else { - logrus.WithError(err).Warnf("Ignoring hosts dir %q", v) - } - } - } + validDirs := validateDirectories(orig) return func(o *opts) { - o.hostsDirs = ss + o.hostsDirs = validDirs } } @@ -100,14 +80,26 @@ func NewHostOptions(ctx context.Context, refHostname string, optFuncs ...Opt) (* } var ho dockerconfig.HostOptions - ho.HostDir = func(s string) (string, error) { - for _, hostsDir := range o.hostsDirs { - found, err := dockerconfig.HostDirFromRoot(hostsDir)(s) - if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { - return found, err + ho.HostDir = func(hostURL string) (string, error) { + regURL, err := Parse(hostURL) + // Docker inconsistencies handling: `index.docker.io` actually expects `docker.io` for hosts.toml on the filesystem + // See https://github.com/containerd/nerdctl/issues/3697 + // FIXME: we need to reevaluate this comparing with what docker does. What should happen for FQ images with alternate docker domains? (eg: registry-1.docker.io) + if regURL.Hostname() == "index.docker.io" { + regURL.Host = "docker.io" + } + + if err != nil { + return "", err + } + dir, err := hostDirsFromRoot(regURL, o.hostsDirs) + if err != nil { + if errors.Is(err, errdefs.ErrNotFound) { + err = nil } + return "", err } - return "", nil + return dir, nil } if o.authCreds != nil { @@ -136,6 +128,10 @@ func NewHostOptions(ctx context.Context, refHostname string, optFuncs ...Opt) (* ho.DefaultScheme = "http" } } + if ho.DefaultScheme == "http" { + // https://github.com/containerd/containerd/issues/9208 + ho.DefaultTLS = nil + } return &ho, nil } @@ -165,85 +161,35 @@ type AuthCreds func(string) (string, string, error) // NewAuthCreds returns AuthCreds that uses $DOCKER_CONFIG/config.json . // AuthCreds can be nil. func NewAuthCreds(refHostname string) (AuthCreds, error) { - // Load does not raise an error on ENOENT - dockerConfigFile, err := dockercliconfig.Load("") + // Note: does not raise an error on ENOENT + credStore, err := NewCredentialsStore("") if err != nil { return nil, err } - // DefaultHost converts "docker.io" to "registry-1.docker.io", - // which is wanted by credFunc . - credFuncExpectedHostname, err := docker.DefaultHost(refHostname) - if err != nil { - return nil, err - } + credFunc := func(host string) (string, string, error) { + rHost, err := Parse(host) + if err != nil { + return "", "", err + } - var credFunc AuthCreds + ac, err := credStore.Retrieve(rHost, true) + if err != nil { + return "", "", err + } - authConfigHostnames := []string{refHostname} - if refHostname == "docker.io" || refHostname == "registry-1.docker.io" { - // "docker.io" appears as ""https://index.docker.io/v1/" in ~/.docker/config.json . - // Unlike other registries, we have to pass the full URL to GetAuthConfig. - authConfigHostnames = append([]string{IndexServer}, refHostname) - } + if ac.IdentityToken != "" { + return "", ac.IdentityToken, nil + } - for _, authConfigHostname := range authConfigHostnames { - // GetAuthConfig does not raise an error on ENOENT - ac, err := dockerConfigFile.GetAuthConfig(authConfigHostname) - if err != nil { - logrus.WithError(err).Warnf("cannot get auth config for authConfigHostname=%q (refHostname=%q)", - authConfigHostname, refHostname) - } else { - // When refHostname is "docker.io": - // - credFuncExpectedHostname: "registry-1.docker.io" - // - credFuncArg: "registry-1.docker.io" - // - authConfigHostname: "https://index.docker.io/v1/" (IndexServer) - // - ac.ServerAddress: "https://index.docker.io/v1/". - if !isAuthConfigEmpty(ac) { - if ac.ServerAddress == "" { - // This can happen with Amazon ECR: https://github.com/containerd/nerdctl/issues/733 - logrus.Debugf("failed to get ac.ServerAddress for authConfigHostname=%q (refHostname=%q)", - authConfigHostname, refHostname) - } else if authConfigHostname == IndexServer { - if ac.ServerAddress != IndexServer { - return nil, fmt.Errorf("expected ac.ServerAddress (%q) to be %q", ac.ServerAddress, IndexServer) - } - } else { - acsaHostname := credentials.ConvertToHostname(ac.ServerAddress) - if acsaHostname != authConfigHostname { - return nil, fmt.Errorf("expected the hostname part of ac.ServerAddress (%q) to be authConfigHostname=%q, got %q", - ac.ServerAddress, authConfigHostname, acsaHostname) - } - } - - if ac.RegistryToken != "" { - // Even containerd/CRI does not support RegistryToken as of v1.4.3, - // so, nobody is actually using RegistryToken? - logrus.Warnf("ac.RegistryToken (for %q) is not supported yet (FIXME)", authConfigHostname) - } - - credFunc = func(credFuncArg string) (string, string, error) { - // credFuncArg should be like "registry-1.docker.io" - if credFuncArg != credFuncExpectedHostname { - return "", "", fmt.Errorf("expected credFuncExpectedHostname=%q (refHostname=%q), got credFuncArg=%q", - credFuncExpectedHostname, refHostname, credFuncArg) - } - if ac.IdentityToken != "" { - return "", ac.IdentityToken, nil - } - return ac.Username, ac.Password, nil - } - break - } + if ac.RegistryToken != "" { + // Even containerd/CRI does not support RegistryToken as of v1.4.3, + // so, nobody is actually using RegistryToken? + log.L.Warnf("ac.RegistryToken (for %q) is not supported yet (FIXME)", rHost.Host) } - } - // credsFunc can be nil here - return credFunc, nil -} -func isAuthConfigEmpty(ac dockercliconfigtypes.AuthConfig) bool { - if ac.IdentityToken != "" || ac.Username != "" || ac.Password != "" || ac.RegistryToken != "" { - return false + return ac.Username, ac.Password, nil } - return true + + return credFunc, nil } diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go deleted file mode 100644 index b452425e7ba..00000000000 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go +++ /dev/null @@ -1,50 +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. -*/ - -/* - Portions from https://github.com/moby/moby/blob/v20.10.18/registry/auth.go#L154-L167 - Copyright (C) Docker/Moby authors. - Licensed under the Apache License, Version 2.0 - NOTICE: https://github.com/moby/moby/blob/v20.10.18/NOTICE -*/ - -package dockerconfigresolver - -import ( - "strings" -) - -// IndexServer is used for user auth and image search -// -// From https://github.com/moby/moby/blob/v20.10.18/registry/config.go#L36-L39 -const IndexServer = "https://index.docker.io/v1/" - -// ConvertToHostname converts a registry url which has http|https prepended -// to just an hostname. -// -// From https://github.com/moby/moby/blob/v20.10.18/registry/auth.go#L154-L167 -func ConvertToHostname(url string) string { - stripped := url - if strings.HasPrefix(url, "http://") { - stripped = strings.TrimPrefix(url, "http://") - } else if strings.HasPrefix(url, "https://") { - stripped = strings.TrimPrefix(url, "https://") - } - - nameParts := strings.SplitN(stripped, "/", 2) - - return nameParts[0] -} diff --git a/pkg/imgutil/dockerconfigresolver/hostsstore.go b/pkg/imgutil/dockerconfigresolver/hostsstore.go new file mode 100644 index 00000000000..12df6961455 --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/hostsstore.go @@ -0,0 +1,66 @@ +/* + 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 dockerconfigresolver + +import ( + "errors" + "os" + + "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/errdefs" + "github.com/containerd/log" +) + +// validateDirectories inspect a slice of strings and returns the ones that are valid readable directories +func validateDirectories(orig []string) []string { + ss := []string{} + for _, v := range orig { + fi, err := os.Stat(v) + if err != nil || !fi.IsDir() { + if !errors.Is(err, os.ErrNotExist) { + log.L.WithError(err).Warnf("Ignoring hosts location %q", v) + } + continue + } + ss = append(ss, v) + } + return ss +} + +// hostDirsFromRoot will retrieve a host.toml file for the namespace host, possibly trying without port +// if the requested port is standard. +// https://github.com/containerd/nerdctl/issues/3047 +func hostDirsFromRoot(registryURL *RegistryURL, dirs []string) (string, error) { + hostsDirs := validateDirectories(dirs) + + // Go through the configured system location to consider for hosts.toml files + for _, hostsDir := range hostsDirs { + found, err := config.HostDirFromRoot(hostsDir)(registryURL.Host) + // If we errored with anything but NotFound, or if we found one, return now + if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { + return found, err + } + // If not found, and the port is standard, try again without the port + if registryURL.Port() == StandardHTTPSPort { + found, err = config.HostDirFromRoot(hostsDir)(registryURL.Hostname()) + if (err != nil && !errors.Is(err, errdefs.ErrNotFound)) || (found != "") { + return found, err + } + } + } + return "", nil +} diff --git a/pkg/imgutil/dockerconfigresolver/registryurl.go b/pkg/imgutil/dockerconfigresolver/registryurl.go new file mode 100644 index 00000000000..ec195e1068c --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/registryurl.go @@ -0,0 +1,136 @@ +/* + 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 dockerconfigresolver + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +// Parse will return a normalized Docker Registry url from the provided string address +func Parse(address string) (*RegistryURL, error) { + var err error + // No address or address as docker.io? Default to standardized index + if address == "" || address == "docker.io" { + address = dockerIndexServer + } + // If it has no scheme, slap one just so we can parse + if !strings.Contains(address, "://") { + address = fmt.Sprintf("%s://%s", schemeHTTPS, address) + } + // Parse it + u, err := url.Parse(address) + if err != nil { + return nil, errors.Join(ErrUnparsableURL, err) + } + sch := scheme(u.Scheme) + // Scheme is entirely disregarded anyhow, so, just drop it all and set to https + if sch == schemeHTTP { + u.Scheme = string(schemeHTTPS) + } else if sch != schemeHTTPS && sch != schemeNerdctlExperimental { + // Docker is wildly buggy when it comes to non-http schemes. Being more defensive. + return nil, ErrUnsupportedScheme + } + // If it has no port, add the standard port explicitly + if u.Port() == "" { + u.Host = u.Hostname() + ":" + StandardHTTPSPort + } + reg := &RegistryURL{URL: *u} + queryParams := u.Query() + nsQuery := queryParams.Get(namespaceQueryParameter) + if nsQuery != "" { + reg.Namespace, err = Parse(nsQuery) + if err != nil { + return nil, err + } + } + return reg, nil +} + +// RegistryURL is a struct that represents a registry namespace or host, meant specifically to deal with +// credentials storage and retrieval inside Docker config file. +type RegistryURL struct { + url.URL + Namespace *RegistryURL +} + +// CanonicalIdentifier returns the identifier expected to be used to save credentials to docker auth config +func (rn *RegistryURL) CanonicalIdentifier() string { + // If it is the docker index over https, port 443, on the /v1/ path, we use the docker fully qualified identifier + if rn.Scheme == string(schemeHTTPS) && rn.Hostname() == "index.docker.io" && rn.Path == "/v1/" && rn.Port() == StandardHTTPSPort || + rn.URL.String() == dockerIndexServer { + return dockerIndexServer + } + // Otherwise, for anything else, we use the hostname+port part + identifier := rn.Host + // If this is a namespaced entry, wrap it, and slap the path as well, as hosts are allowed to be non-compliant + if rn.Namespace != nil { + identifier = fmt.Sprintf("%s://%s/host/%s%s", schemeNerdctlExperimental, rn.Namespace.CanonicalIdentifier(), identifier, rn.Path) + } + return identifier +} + +// AllIdentifiers returns a list of identifiers that may have been used to save credentials, +// accounting for legacy formats including scheme, with and without ports +func (rn *RegistryURL) AllIdentifiers() []string { + canonicalID := rn.CanonicalIdentifier() + fullList := []string{ + // This is rn.Host, and always have a port (see parsing) + canonicalID, + } + // If the canonical identifier points to Docker Hub, or is one of our experimental ids, there is no alternative / legacy id + if canonicalID == dockerIndexServer || rn.Namespace != nil { + return fullList + } + + // Docker behavior: if the domain was index.docker.io over 443, we are allowed to additionally read the canonical + // docker credentials + if rn.Port() == StandardHTTPSPort { + if rn.Hostname() == "index.docker.io" || rn.Hostname() == "registry-1.docker.io" { + fullList = append(fullList, dockerIndexServer) + } + } + + // Add legacy variants + fullList = append(fullList, + fmt.Sprintf("%s://%s", schemeHTTPS, rn.Host), + fmt.Sprintf("%s://%s", schemeHTTP, rn.Host), + ) + + // Note that docker does not try to be smart wrt explicit port vs. implied port + // If standard port, allow retrieving credentials from the variant without a port as well + if rn.Port() == StandardHTTPSPort { + fullList = append( + fullList, + rn.Hostname(), + fmt.Sprintf("%s://%s", schemeHTTPS, rn.Hostname()), + fmt.Sprintf("%s://%s", schemeHTTP, rn.Hostname()), + ) + } + + return fullList +} + +func (rn *RegistryURL) IsLocalhost() bool { + // Containerd exposes both a IsLocalhost and a MatchLocalhost method + // There does not seem to be a clear reason for the duplication, nor the differences in implementation. + // Either way, they both reparse the host with net.SplitHostPort, which is unnecessary here + return rn.Hostname() == "localhost" || net.ParseIP(rn.Hostname()).IsLoopback() +} diff --git a/pkg/imgutil/dockerconfigresolver/registryurl_test.go b/pkg/imgutil/dockerconfigresolver/registryurl_test.go new file mode 100644 index 00000000000..d10a02028e2 --- /dev/null +++ b/pkg/imgutil/dockerconfigresolver/registryurl_test.go @@ -0,0 +1,194 @@ +/* + 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 dockerconfigresolver + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestURLParsingAndID(t *testing.T) { + tests := []struct { + address string + error error + identifier string + allIDs []string + isLocalhost bool + }{ + { + address: "∞://", + error: ErrUnparsableURL, + }, + { + address: "whatever://", + error: ErrUnsupportedScheme, + }, + { + address: "", + identifier: "https://index.docker.io/v1/", + allIDs: []string{"https://index.docker.io/v1/"}, + }, + { + address: "https://index.docker.io/v1/", + identifier: "https://index.docker.io/v1/", + allIDs: []string{"https://index.docker.io/v1/"}, + }, + { + address: "index.docker.io", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "index.docker.io/whatever", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "http://index.docker.io", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "index.docker.io:80", + identifier: "index.docker.io:80", + allIDs: []string{ + "index.docker.io:80", + "https://index.docker.io:80", "http://index.docker.io:80", + }, + }, + { + address: "index.docker.io:8080", + identifier: "index.docker.io:8080", + allIDs: []string{ + "index.docker.io:8080", + "https://index.docker.io:8080", "http://index.docker.io:8080", + }, + }, + { + address: "foo.docker.io", + identifier: "foo.docker.io:443", + allIDs: []string{ + "foo.docker.io:443", "https://foo.docker.io:443", "http://foo.docker.io:443", + "foo.docker.io", "https://foo.docker.io", "http://foo.docker.io", + }, + }, + { + address: "docker.io", + identifier: "https://index.docker.io/v1/", + allIDs: []string{"https://index.docker.io/v1/"}, + }, + { + address: "docker.io/whatever", + identifier: "docker.io:443", + allIDs: []string{ + "docker.io:443", "https://docker.io:443", "http://docker.io:443", + "docker.io", "https://docker.io", "http://docker.io", + }, + }, + { + address: "http://docker.io", + identifier: "docker.io:443", + allIDs: []string{ + "docker.io:443", "https://docker.io:443", "http://docker.io:443", + "docker.io", "https://docker.io", "http://docker.io", + }, + }, + { + address: "docker.io:80", + identifier: "docker.io:80", + allIDs: []string{ + "docker.io:80", + "https://docker.io:80", "http://docker.io:80", + }, + }, + { + address: "docker.io:8080", + identifier: "docker.io:8080", + allIDs: []string{ + "docker.io:8080", + "https://docker.io:8080", "http://docker.io:8080", + }, + }, + { + address: "anything/whatever?u=v&w=y;foo=bar#frag=o", + identifier: "anything:443", + allIDs: []string{ + "anything:443", "https://anything:443", "http://anything:443", + "anything", "https://anything", "http://anything", + }, + }, + { + address: "https://registry-host.com/subpath/something?bar=bar&ns=registry-namespace.com&foo=foo", + identifier: "nerdctl-experimental://registry-namespace.com:443/host/registry-host.com:443/subpath/something", + allIDs: []string{ + "nerdctl-experimental://registry-namespace.com:443/host/registry-host.com:443/subpath/something", + }, + }, + { + address: "localhost:1234", + identifier: "localhost:1234", + allIDs: []string{ + "localhost:1234", "https://localhost:1234", "http://localhost:1234", + }, + }, + { + address: "127.0.0.1:1234", + identifier: "127.0.0.1:1234", + allIDs: []string{ + "127.0.0.1:1234", "https://127.0.0.1:1234", "http://127.0.0.1:1234", + }, + }, + { + address: "[::1]:1234", + identifier: "[::1]:1234", + allIDs: []string{ + "[::1]:1234", "https://[::1]:1234", "http://[::1]:1234", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.address, func(t *testing.T) { + reg, err := Parse(tc.address) + assert.ErrorIs(t, err, tc.error) + if err == nil { + assert.Equal(t, reg.CanonicalIdentifier(), tc.identifier) + allIDs := reg.AllIdentifiers() + assert.Equal(t, len(allIDs), len(tc.allIDs)) + for k, v := range tc.allIDs { + assert.Equal(t, allIDs[k], v) + } + } + }) + } +} diff --git a/pkg/imgutil/fetch/fetch.go b/pkg/imgutil/fetch/fetch.go new file mode 100644 index 00000000000..c9ab04f5533 --- /dev/null +++ b/pkg/imgutil/fetch/fetch.go @@ -0,0 +1,92 @@ +/* + 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 fetch + +import ( + "context" + "io" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/jobs" + "github.com/containerd/nerdctl/v2/pkg/platformutil" +) + +// Config for content fetch +type Config struct { + // Resolver + Resolver remotes.Resolver + // ProgressOutput to display progress + ProgressOutput io.Writer + // RemoteOpts, e.g. containerd.WithPullUnpack. + // + // Regardless to RemoteOpts, the following opts are always set: + // WithResolver, WithImageHandler, WithSchema1Conversion + // + // RemoteOpts related to unpacking can be set only when len(Platforms) is 1. + RemoteOpts []containerd.RemoteOpt + Platforms []ocispec.Platform // empty for all-platforms +} + +func Fetch(ctx context.Context, client *containerd.Client, ref string, config *Config) error { + ongoing := jobs.New(ref) + + pctx, stopProgress := context.WithCancel(ctx) + progress := make(chan struct{}) + + go func() { + if config.ProgressOutput != nil { + // no progress bar, because it hides some debug logs + jobs.ShowProgress(pctx, ongoing, client.ContentStore(), config.ProgressOutput) + } + close(progress) + }() + + h := images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + if desc.MediaType != images.MediaTypeDockerSchema1Manifest { + ongoing.Add(desc) + } + return nil, nil + }) + + log.G(pctx).WithField("image", ref).Debug("fetching") + platformMC := platformutil.NewMatchComparerFromOCISpecPlatformSlice(config.Platforms) + opts := []containerd.RemoteOpt{ + containerd.WithResolver(config.Resolver), + containerd.WithImageHandler(h), + //nolint:staticcheck + containerd.WithSchema1Conversion, //lint:ignore SA1019 nerdctl should support schema1 as well. + containerd.WithPlatformMatcher(platformMC), + } + opts = append(opts, config.RemoteOpts...) + + // Note that client.Fetch does not unpack + _, err := client.Fetch(pctx, ref, opts...) + + stopProgress() + if err != nil { + return err + } + + <-progress + return nil +} diff --git a/pkg/imgutil/filtering.go b/pkg/imgutil/filtering.go index 3e1445c8c87..5555380ba4c 100644 --- a/pkg/imgutil/filtering.go +++ b/pkg/imgutil/filtering.go @@ -18,36 +18,47 @@ package imgutil import ( "context" + "errors" "fmt" "regexp" "strings" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - dockerreference "github.com/containerd/containerd/reference/docker" - "github.com/containerd/nerdctl/pkg/referenceutil" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) // Filter types supported to filter images. -var ( +const ( FilterBeforeType = "before" FilterSinceType = "since" + FilterUntilType = "until" FilterLabelType = "label" FilterReferenceType = "reference" FilterDanglingType = "dangling" ) +var ( + errMultipleUntilFilters = errors.New("more than one until filter provided") + errNoUntilTimestamp = errors.New("no until timestamp provided") + errUnparsableUntilTimestamp = errors.New("unable to parse until timestamp") +) + // Filters contains all types of filters to filter images. type Filters struct { Before []string Since []string + Until string Labels map[string]string Reference []string Dangling *bool } +type Filter func([]images.Image) ([]images.Image, error) + // ParseFilters parse filter strings. func ParseFilters(filters []string) (*Filters, error) { f := &Filters{Labels: make(map[string]string)} @@ -68,20 +79,27 @@ func ParseFilters(filters []string) (*Filters, error) { } f.Dangling = &isDangling } else if tempFilterToken[0] == FilterBeforeType { - canonicalRef, err := referenceutil.ParseAny(tempFilterToken[1]) + parsedReference, err := referenceutil.Parse(tempFilterToken[1]) if err != nil { return nil, err } - f.Before = append(f.Before, fmt.Sprintf("name==%s", canonicalRef.String())) + f.Before = append(f.Before, fmt.Sprintf("name==%s", parsedReference.String())) f.Before = append(f.Before, fmt.Sprintf("name==%s", tempFilterToken[1])) } else if tempFilterToken[0] == FilterSinceType { - canonicalRef, err := referenceutil.ParseAny(tempFilterToken[1]) + parsedReference, err := referenceutil.Parse(tempFilterToken[1]) if err != nil { return nil, err } - f.Since = append(f.Since, fmt.Sprintf("name==%s", canonicalRef.String())) + f.Since = append(f.Since, fmt.Sprintf("name==%s", parsedReference.String())) f.Since = append(f.Since, fmt.Sprintf("name==%s", tempFilterToken[1])) + } else if tempFilterToken[0] == FilterUntilType { + if len(tempFilterToken[0]) == 0 { + return nil, errNoUntilTimestamp + } else if len(f.Until) > 0 { + return nil, errMultipleUntilFilters + } + f.Until = tempFilterToken[1] } else if tempFilterToken[0] == FilterLabelType { // To support filtering labels by keys. f.Labels[tempFilterToken[1]] = "" @@ -103,103 +121,235 @@ func ParseFilters(filters []string) (*Filters, error) { return f, nil } -// FilterImages returns images in `labelImages` that are created -// before MAX(beforeImages.CreatedAt) and after MIN(sinceImages.CreatedAt). -func FilterImages(labelImages []images.Image, beforeImages []images.Image, sinceImages []images.Image) []images.Image { - var filteredImages []images.Image - maxTime := time.Now() - minTime := time.Date(1970, time.Month(1), 1, 0, 0, 0, 0, time.UTC) - if len(beforeImages) > 0 { - maxTime = beforeImages[0].CreatedAt - for _, value := range beforeImages { - if value.CreatedAt.After(maxTime) { - maxTime = value.CreatedAt - } +// ApplyFilters applies each filter function in the order provided +// and returns the resulting filtered image list. +func ApplyFilters(imageList []images.Image, filters ...Filter) ([]images.Image, error) { + var err error + for _, filter := range filters { + imageList, err = filter(imageList) + if err != nil { + return []images.Image{}, err } } - if len(sinceImages) > 0 { - minTime = sinceImages[0].CreatedAt - for _, value := range sinceImages { - if value.CreatedAt.Before(minTime) { - minTime = value.CreatedAt + return imageList, nil +} + +// FilterByCreatedAt filters an image list to images created before MAX(before..CreatedAt) +// and after MIN(since..CreatedAt). +func FilterByCreatedAt(ctx context.Context, client *containerd.Client, before []string, since []string) Filter { + return func(imageList []images.Image) ([]images.Image, error) { + var ( + minTime = time.Date(1970, time.Month(1), 1, 0, 0, 0, 0, time.UTC) + maxTime = time.Now() + ) + + fetchImageNames := func(names []string) string { + parsedNames := make([]string, 0, len(names)) + for _, name := range names { + parsedNames = append(parsedNames, strings.TrimPrefix(name, "name==")) } + return strings.Join(parsedNames, ",") } - } - for _, image := range labelImages { - if image.CreatedAt.After(minTime) && image.CreatedAt.Before(maxTime) { - filteredImages = append(filteredImages, image) - } - } - return filteredImages -} -// FilterByReference filters images using references given in `filters`. -func FilterByReference(imageList []images.Image, filters []string) ([]images.Image, error) { - var filteredImageList []images.Image - logrus.Debug(filters) - for _, image := range imageList { - logrus.Debug(image.Name) - var matches int - for _, f := range filters { - var ref dockerreference.Reference - var err error - ref, err = dockerreference.ParseAnyReference(image.Name) + imageStore := client.ImageService() + if len(before) > 0 { + beforeImages, err := imageStore.List(ctx, before...) if err != nil { - return nil, fmt.Errorf("unable to parse image name: %s while filtering by reference because of %s", image.Name, err.Error()) + return []images.Image{}, err } + if len(beforeImages) == 0 { + //nolint:stylecheck + return []images.Image{}, fmt.Errorf("No such image: %s", fetchImageNames(before)) + } + maxTime = beforeImages[0].CreatedAt + for _, image := range beforeImages { + if image.CreatedAt.After(maxTime) { + maxTime = image.CreatedAt + } + } + } - familiarMatch, err := dockerreference.FamiliarMatch(f, ref) + if len(since) > 0 { + sinceImages, err := imageStore.List(ctx, since...) if err != nil { - return nil, err + return []images.Image{}, err } - regexpMatch, err := regexp.MatchString(f, image.Name) - if err != nil { - return nil, err + if len(sinceImages) == 0 { + //nolint:stylecheck + return []images.Image{}, fmt.Errorf("No such image: %s", fetchImageNames(since)) } - if familiarMatch || regexpMatch { - matches++ + minTime = sinceImages[0].CreatedAt + for _, image := range sinceImages { + if image.CreatedAt.Before(minTime) { + minTime = image.CreatedAt + } } } - if matches == len(filters) { - filteredImageList = append(filteredImageList, image) - } + + return filter(imageList, func(i images.Image) (bool, error) { + return imageCreatedBetween(i, minTime, maxTime), nil + }) } - return filteredImageList, nil } -// FilterDangling filters dangling images (or keeps if `dangling` == false). -func FilterDangling(imageList []images.Image, dangling bool) []images.Image { - var filtered []images.Image - for _, image := range imageList { - _, tag := ParseRepoTag(image.Name) +// FilterUntil filters images created before the provided timestamp. +func FilterUntil(until string) Filter { + return func(imageList []images.Image) ([]images.Image, error) { + if len(until) == 0 { + return []images.Image{}, errNoUntilTimestamp + } + + var ( + parsedTime time.Time + err error + ) - if dangling && tag == "" { - filtered = append(filtered, image) + type parseUntilFunc func(string) (time.Time, error) + parsingFuncs := []parseUntilFunc{ + func(until string) (time.Time, error) { + return time.Parse(time.RFC3339, until) + }, + func(until string) (time.Time, error) { + return time.Parse(time.RFC3339Nano, until) + }, + func(until string) (time.Time, error) { + return time.Parse(time.DateOnly, until) + }, + func(until string) (time.Time, error) { + // Go duration strings + d, err := time.ParseDuration(until) + if err != nil { + return time.Time{}, err + } + return time.Now().Add(-d), nil + }, } - if !dangling && tag != "" { - filtered = append(filtered, image) + + for _, parse := range parsingFuncs { + parsedTime, err = parse(until) + if err != nil { + continue + } + break } + + if err != nil { + return []images.Image{}, errUnparsableUntilTimestamp + } + + return filter(imageList, func(i images.Image) (bool, error) { + return imageCreatedBefore(i, parsedTime), nil + }) } - return filtered } -// FilterByLabel filters images based on labels given in `filters`. -func FilterByLabel(ctx context.Context, client *containerd.Client, imageList []images.Image, filters map[string]string) ([]images.Image, error) { - for lk, lv := range filters { - var imageLabels []images.Image - for _, img := range imageList { - ci := containerd.NewImage(client, img) - cfg, _, err := ReadImageConfig(ctx, ci) +// FilterByLabel filters an image list based on labels applied to the image's config specification for the platform. +// Any matching label will include the image in the list. +func FilterByLabel(ctx context.Context, client *containerd.Client, labels map[string]string) Filter { + return func(imageList []images.Image) ([]images.Image, error) { + return filter(imageList, func(i images.Image) (bool, error) { + clientImage := containerd.NewImage(client, i) + imageCfg, _, err := ReadImageConfig(ctx, clientImage) if err != nil { - return nil, err + // Stop-gap measure. Do not hard error if some images config cannot be read. + // See https://github.com/containerd/nerdctl/issues/3516 + log.G(ctx).WithError(err).Errorf("failed reading image config for %s (%s)", clientImage.Name(), clientImage.Platform()) + return false, nil } - if val, ok := cfg.Config.Labels[lk]; ok { - if val == lv || lv == "" { - imageLabels = append(imageLabels, img) - } + return matchesAllLabels(imageCfg.Config.Labels, labels), nil + }) + } +} + +// FilterByReference filters an image list based on +// matching the provided reference patterns +func FilterByReference(referencePatterns []string) Filter { + return func(imageList []images.Image) ([]images.Image, error) { + return filter(imageList, func(i images.Image) (bool, error) { + return matchesReferences(i, referencePatterns) + }) + } +} + +// FilterDanglingImages filters an image list for dangling (untagged) images. +func FilterDanglingImages() Filter { + return func(imageList []images.Image) ([]images.Image, error) { + return filter(imageList, func(i images.Image) (bool, error) { + return isDangling(i), nil + }) + } +} + +// FilterTaggedImages filters an image list for tagged images. +func FilterTaggedImages() Filter { + return func(imageList []images.Image) ([]images.Image, error) { + return filter(imageList, func(i images.Image) (bool, error) { + return !isDangling(i), nil + }) + } +} + +func filter[T any](items []T, f func(item T) (bool, error)) ([]T, error) { + filteredItems := make([]T, 0, len(items)) + for _, item := range items { + ok, err := f(item) + if err != nil { + return []T{}, err + } else if ok { + filteredItems = append(filteredItems, item) + } + } + return filteredItems, nil +} + +func imageCreatedBetween(image images.Image, minTime time.Time, maxTime time.Time) bool { + return image.CreatedAt.After(minTime) && image.CreatedAt.Before(maxTime) +} + +func imageCreatedBefore(image images.Image, maxTime time.Time) bool { + return image.CreatedAt.Before(maxTime) +} + +func matchesAllLabels(imageCfgLabels map[string]string, filterLabels map[string]string) bool { + var matches int + for lk, lv := range filterLabels { + if val, ok := imageCfgLabels[lk]; ok { + if val == lv || lv == "" { + matches++ } } - imageList = imageLabels } - return imageList, nil + return matches == len(filterLabels) +} + +func matchesReferences(image images.Image, referencePatterns []string) (bool, error) { + var matches int + + parsedReference, err := referenceutil.Parse(image.Name) + if err != nil { + return false, err + } + + for _, pattern := range referencePatterns { + familiarMatch, err := parsedReference.FamiliarMatch(pattern) + if err != nil { + return false, err + } + + regexpMatch, err := regexp.MatchString(pattern, image.Name) + if err != nil { + return false, err + } + + if familiarMatch || regexpMatch { + matches++ + } + } + + return matches == len(referencePatterns), nil +} + +func isDangling(image images.Image) bool { + _, tag := ParseRepoTag(image.Name) + return tag == "" } diff --git a/pkg/imgutil/filtering_test.go b/pkg/imgutil/filtering_test.go new file mode 100644 index 00000000000..7d82cb2ce60 --- /dev/null +++ b/pkg/imgutil/filtering_test.go @@ -0,0 +1,491 @@ +/* + 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 ( + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/containerd/v2/core/images" +) + +func TestApplyFilters(t *testing.T) { + tests := []struct { + name string + images []images.Image + filters []Filter + expectedImages []images.Image + expectedErr error + }{ + { + name: "EmptyList", + images: []images.Image{}, + filters: []Filter{ + FilterDanglingImages(), + }, + expectedImages: []images.Image{}, + }, + { + name: "ApplyNoFilters", + images: []images.Image{ + { + Name: "", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + }, + filters: []Filter{}, + expectedImages: []images.Image{ + { + Name: "", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + }, + }, + { + name: "ApplySingleFilter", + images: []images.Image{ + { + Name: "", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + }, + filters: []Filter{ + FilterDanglingImages(), + }, + expectedImages: []images.Image{ + { + Name: "", + }, + }, + }, + { + name: "ApplyMultipleFilters", + images: []images.Image{ + { + Name: "", + }, + { + Name: "alpine:3.19", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "public.ecr.aws/docker/library/hello-world:latest", + }, + }, + filters: []Filter{ + FilterTaggedImages(), + FilterByReference([]string{"hello-world"}), + }, + expectedImages: []images.Image{ + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "public.ecr.aws/docker/library/hello-world:latest", + }, + }, + }, + { + name: "ReturnErrorAndEmptyListOnFilterError", + images: []images.Image{ + { + Name: ":", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + }, + filters: []Filter{ + FilterDanglingImages(), + FilterUntil(""), + }, + expectedImages: []images.Image{}, + expectedErr: errNoUntilTimestamp, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualImages, err := ApplyFilters(test.images, test.filters...) + if test.expectedErr == nil { + assert.NilError(t, err) + } else { + assert.ErrorIs(t, err, test.expectedErr) + } + assert.Equal(t, len(actualImages), len(test.expectedImages)) + assert.DeepEqual(t, actualImages, test.expectedImages) + }) + } +} + +func TestFilterUntil(t *testing.T) { + now := time.Now().UTC() + + tests := []struct { + name string + until string + images []images.Image + expectedImages []images.Image + expectedErr error + }{ + { + name: "EmptyTimestampReturnsError", + until: "", + images: []images.Image{}, + expectedImages: []images.Image{}, + expectedErr: errNoUntilTimestamp, + }, + { + name: "UnparseableTimestampReturnsError", + until: "-2006-01-02T15:04:05Z07:00", + images: []images.Image{}, + expectedImages: []images.Image{}, + expectedErr: errUnparsableUntilTimestamp, + }, + { + name: "ImagesOlderThan3Hours(Go duration)", + until: "3h", + images: []images.Image{ + { + Name: "image:yesterday", + CreatedAt: now.Add(-24 * time.Hour), + }, + { + Name: "image:today", + CreatedAt: now.Add(-12 * time.Hour), + }, + { + Name: "image:latest", + CreatedAt: now, + }, + }, + expectedImages: []images.Image{ + { + Name: "image:yesterday", + CreatedAt: now.Add(-24 * time.Hour), + }, + { + Name: "image:today", + CreatedAt: now.Add(-12 * time.Hour), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualImages, err := FilterUntil(test.until)(test.images) + if test.expectedErr == nil { + assert.NilError(t, err) + } else { + assert.ErrorIs(t, err, test.expectedErr) + } + assert.Equal(t, len(actualImages), len(test.expectedImages)) + assert.DeepEqual(t, actualImages, test.expectedImages) + }) + } +} + +func TestFilterByReference(t *testing.T) { + tests := []struct { + name string + referencePatterns []string + images []images.Image + expectedImages []images.Image + expectedErr error + }{ + { + name: "EmptyList", + images: []images.Image{}, + expectedImages: []images.Image{}, + }, + { + name: "MatchByReference", + images: []images.Image{ + { + Name: "foo:latest", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "public.ecr.aws/docker/library/hello-world:latest", + }, + }, + referencePatterns: []string{"hello-world"}, + expectedImages: []images.Image{ + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "public.ecr.aws/docker/library/hello-world:latest", + }, + }, + }, + { + name: "NoMatchExists", + images: []images.Image{ + { + Name: "foo:latest", + }, + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "public.ecr.aws/docker/library/hello-world:latest", + }, + }, + referencePatterns: []string{"foobar"}, + expectedImages: []images.Image{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualImages, err := FilterByReference(test.referencePatterns)(test.images) + if test.expectedErr == nil { + assert.NilError(t, err) + } else { + assert.ErrorIs(t, err, test.expectedErr) + } + assert.Equal(t, len(actualImages), len(test.expectedImages)) + assert.DeepEqual(t, actualImages, test.expectedImages) + }) + } +} + +func TestFilterDanglingImages(t *testing.T) { + tests := []struct { + name string + dangling bool + images []images.Image + expectedImages []images.Image + }{ + { + name: "EmptyList", + dangling: true, + images: []images.Image{}, + expectedImages: []images.Image{}, + }, + { + name: "IsDangling", + dangling: true, + images: []images.Image{ + { + Name: "", + Labels: map[string]string{"ref": "dangling1"}, + }, + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "", + Labels: map[string]string{"ref": "dangling2"}, + }, + }, + expectedImages: []images.Image{ + { + Name: "", + Labels: map[string]string{"ref": "dangling1"}, + }, + { + Name: "", + Labels: map[string]string{"ref": "dangling2"}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualImages, err := FilterDanglingImages()(test.images) + assert.NilError(t, err) + assert.Equal(t, len(actualImages), len(test.expectedImages)) + assert.DeepEqual(t, actualImages, test.expectedImages) + }) + } +} + +func TestFilterTaggedImages(t *testing.T) { + tests := []struct { + name string + dangling bool + images []images.Image + expectedImages []images.Image + }{ + { + name: "EmptyList", + dangling: true, + images: []images.Image{}, + expectedImages: []images.Image{}, + }, + { + name: "IsTagged", + dangling: true, + images: []images.Image{ + { + Name: "", + Labels: map[string]string{"ref": "dangling1"}, + }, + { + Name: "docker.io/library/hello-world:latest", + }, + { + Name: "", + Labels: map[string]string{"ref": "dangling2"}, + }, + }, + expectedImages: []images.Image{ + { + Name: "docker.io/library/hello-world:latest", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualImages, err := FilterTaggedImages()(test.images) + assert.NilError(t, err) + assert.Equal(t, len(actualImages), len(test.expectedImages)) + assert.DeepEqual(t, actualImages, test.expectedImages) + }) + } +} + +func TestImageCreatedBetween(t *testing.T) { + var ( + unixEpoch = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + y2k = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + now = time.Now() + ) + tests := []struct { + name string + image images.Image + lhs time.Time + rhs time.Time + fallsBetween bool + }{ + { + name: "PreviousImage", + image: images.Image{ + CreatedAt: unixEpoch, + }, + lhs: y2k, + rhs: now, + fallsBetween: false, + }, + { + name: "AfterImage", + image: images.Image{ + CreatedAt: now, + }, + lhs: unixEpoch, + rhs: y2k, + fallsBetween: false, + }, + { + name: "InBetweenTimeImage", + image: images.Image{ + CreatedAt: y2k, + }, + lhs: unixEpoch, + rhs: now, + fallsBetween: true, + }, + { + name: "ExclusiveLeft", + image: images.Image{ + CreatedAt: unixEpoch, + }, + lhs: unixEpoch, + rhs: now, + fallsBetween: false, + }, + { + name: "ExclusiveRight", + image: images.Image{ + CreatedAt: now, + }, + lhs: unixEpoch, + rhs: now, + fallsBetween: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, imageCreatedBetween(test.image, test.lhs, test.rhs), test.fallsBetween) + }) + } +} + +func TestMatchesAnyLabel(t *testing.T) { + tests := []struct { + name string + imageLabels map[string]string + labelsToMatch map[string]string + matches bool + }{ + { + name: "ImageHasNoLabels", + imageLabels: map[string]string{}, + labelsToMatch: map[string]string{"foo": "bar"}, + matches: false, + }, + { + name: "SingleMatchingLabel", + imageLabels: map[string]string{"org": "com.example.nerdctl"}, + labelsToMatch: map[string]string{"org": "com.example.nerdctl"}, + matches: true, + }, + { + name: "KeyOnlyMatchingLabel", + imageLabels: map[string]string{"org": "com.example.nerdctl"}, + labelsToMatch: map[string]string{"org": ""}, + matches: true, + }, + { + name: "KeyValueDoesNotMatch", + imageLabels: map[string]string{"org": "com.example.nerdctl"}, + labelsToMatch: map[string]string{"org": "com.example.containerd"}, + matches: false, + }, + { + name: "AllMatchingLabel", + imageLabels: map[string]string{"org": "com.example.nerdctl", "foo": "bar"}, + labelsToMatch: map[string]string{"org": "com.example.containerd", "foo": "bar"}, + matches: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, matchesAllLabels(test.imageLabels, test.labelsToMatch), test.matches) + }) + } +} diff --git a/pkg/imgutil/imgutil.go b/pkg/imgutil/imgutil.go index 14e04c40705..3f8076df9f4 100644 --- a/pkg/imgutil/imgutil.go +++ b/pkg/imgutil/imgutil.go @@ -19,27 +19,31 @@ package imgutil import ( "context" "encoding/json" + "errors" "fmt" - "io" + "net/http" "reflect" - "github.com/containerd/containerd" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/platforms" - refdocker "github.com/containerd/containerd/reference/docker" - "github.com/containerd/containerd/remotes" - "github.com/containerd/containerd/snapshots" - "github.com/containerd/imgcrypt" - "github.com/containerd/imgcrypt/images/encryption" - "github.com/containerd/nerdctl/pkg/errutil" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" - "github.com/containerd/nerdctl/pkg/imgutil/pull" - "github.com/docker/docker/errdefs" "github.com/opencontainers/image-spec/identity" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + + 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/core/remotes" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/errdefs" + "github.com/containerd/imgcrypt/v2" + "github.com/containerd/imgcrypt/v2/images/encryption" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/errutil" + "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/referenceutil" ) // EnsuredImage contains the image existed in containerd and its metadata. @@ -57,7 +61,7 @@ type PullMode = string // GetExistingImage returns the specified image if exists in containerd. Return errdefs.NotFound() if not exists. func GetExistingImage(ctx context.Context, client *containerd.Client, snapshotter, rawRef string, platform ocispec.Platform) (*EnsuredImage, error) { var res *EnsuredImage - imagewalker := &imagewalker.ImageWalker{ + imgwalker := &imagewalker.ImageWalker{ Client: client, OnFound: func(ctx context.Context, found imagewalker.Found) error { if res != nil { @@ -85,15 +89,15 @@ func GetExistingImage(ctx context.Context, client *containerd.Client, snapshotte return nil }, } - count, err := imagewalker.Walk(ctx, rawRef) + count, err := imgwalker.Walk(ctx, rawRef) if err != nil { return nil, err } if count == 0 { - return nil, errdefs.NotFound(fmt.Errorf("got count 0 after walking")) + return nil, errors.Join(errdefs.ErrNotFound, errors.New("got count 0 after walking")) } if res == nil { - return nil, errdefs.NotFound(fmt.Errorf("got nil res after walking")) + return nil, errors.Join(errdefs.ErrNotFound, errors.New("got nil res after walking")) } return res, nil } @@ -101,64 +105,60 @@ func GetExistingImage(ctx context.Context, client *containerd.Client, snapshotte // EnsureImage ensures the image. // // # When insecure is set, skips verifying certs, and also falls back to HTTP when the registry does not speak HTTPS -// -// FIXME: this func has too many args -func EnsureImage(ctx context.Context, client *containerd.Client, stdout, stderr io.Writer, snapshotter, rawRef string, mode PullMode, insecure bool, hostsDirs []string, ocispecPlatforms []ocispec.Platform, unpack *bool, quiet bool) (*EnsuredImage, error) { - switch mode { +func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, options types.ImagePullOptions) (*EnsuredImage, error) { + switch options.Mode { case "always", "missing", "never": // NOP default: - return nil, fmt.Errorf("unexpected pull mode: %q", mode) + return nil, fmt.Errorf("unexpected pull mode: %q", options.Mode) } // if not `always` pull and given one platform and image found locally, return existing image directly. - if mode != "always" && len(ocispecPlatforms) == 1 { - if res, err := GetExistingImage(ctx, client, snapshotter, rawRef, ocispecPlatforms[0]); err == nil { + if options.Mode != "always" && len(options.OCISpecPlatform) == 1 { + if res, err := GetExistingImage(ctx, client, options.GOptions.Snapshotter, rawRef, options.OCISpecPlatform[0]); err == nil { return res, nil } else if !errdefs.IsNotFound(err) { return nil, err } } - if mode == "never" { + if options.Mode == "never" { return nil, fmt.Errorf("image not available: %q", rawRef) } - named, err := refdocker.ParseDockerRef(rawRef) + parsedReference, err := referenceutil.Parse(rawRef) if err != nil { return nil, err } - ref := named.String() - refDomain := refdocker.Domain(named) var dOpts []dockerconfigresolver.Opt - if insecure { - logrus.Warnf("skipping verifying HTTPS certs for %q", refDomain) + if options.GOptions.InsecureRegistry { + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", parsedReference.Domain) dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(hostsDirs)) - resolver, err := dockerconfigresolver.New(ctx, refDomain, dOpts...) + dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) + resolver, err := dockerconfigresolver.New(ctx, parsedReference.Domain, dOpts...) if err != nil { return nil, err } - img, err := PullImage(ctx, client, stdout, stderr, snapshotter, resolver, ref, ocispecPlatforms, unpack, quiet) + img, err := PullImage(ctx, client, resolver, parsedReference.String(), options) if 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 !errutil.IsErrHTTPResponseToHTTPSClient(err) && !errutil.IsErrConnectionRefused(err) { + if !errors.Is(err, http.ErrSchemeMismatch) && !errutil.IsErrConnectionRefused(err) { return nil, err } - if insecure { - logrus.WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) + if options.GOptions.InsecureRegistry { + log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", parsedReference.Domain) dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) - resolver, err = dockerconfigresolver.New(ctx, refDomain, dOpts...) + resolver, err = dockerconfigresolver.New(ctx, parsedReference.Domain, dOpts...) if err != nil { return nil, err } - return PullImage(ctx, client, stdout, stderr, snapshotter, resolver, ref, ocispecPlatforms, unpack, quiet) + return PullImage(ctx, client, resolver, parsedReference.String(), options) } - logrus.WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) - logrus.Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + log.G(ctx).WithError(err).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 nil, err } @@ -167,25 +167,23 @@ func EnsureImage(ctx context.Context, client *containerd.Client, stdout, stderr // ResolveDigest resolves `rawRef` and returns its descriptor digest. func ResolveDigest(ctx context.Context, rawRef string, insecure bool, hostsDirs []string) (string, error) { - named, err := refdocker.ParseDockerRef(rawRef) + parsedReference, err := referenceutil.Parse(rawRef) if err != nil { return "", err } - ref := named.String() - refDomain := refdocker.Domain(named) var dOpts []dockerconfigresolver.Opt if insecure { - logrus.Warnf("skipping verifying HTTPS certs for %q", refDomain) + log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", parsedReference.Domain) dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) } dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(hostsDirs)) - resolver, err := dockerconfigresolver.New(ctx, refDomain, dOpts...) + resolver, err := dockerconfigresolver.New(ctx, parsedReference.Domain, dOpts...) if err != nil { return "", err } - _, desc, err := resolver.Resolve(ctx, ref) + _, desc, err := resolver.Resolve(ctx, parsedReference.String()) if err != nil { return "", err } @@ -194,7 +192,7 @@ func ResolveDigest(ctx context.Context, rawRef string, insecure bool, hostsDirs } // PullImage pulls an image using the specified resolver. -func PullImage(ctx context.Context, client *containerd.Client, stdout, stderr io.Writer, snapshotter string, resolver remotes.Resolver, ref string, ocispecPlatforms []ocispec.Platform, unpack *bool, quiet bool) (*EnsuredImage, error) { +func PullImage(ctx context.Context, client *containerd.Client, resolver remotes.Resolver, ref string, options types.ImagePullOptions) (*EnsuredImage, error) { ctx, done, err := client.WithLease(ctx) if err != nil { return nil, err @@ -205,24 +203,27 @@ func PullImage(ctx context.Context, client *containerd.Client, stdout, stderr io config := &pull.Config{ Resolver: resolver, RemoteOpts: []containerd.RemoteOpt{}, - Platforms: ocispecPlatforms, // empty for all-platforms + Platforms: options.OCISpecPlatform, // empty for all-platforms } - if !quiet { - config.ProgressOutput = stderr + if !options.Quiet { + config.ProgressOutput = options.Stderr + if options.ProgressOutputToStdout { + config.ProgressOutput = options.Stdout + } } // unpack(B) if given 1 platform unless specified by `unpack` - unpackB := len(ocispecPlatforms) == 1 - if unpack != nil { - unpackB = *unpack - if unpackB && len(ocispecPlatforms) != 1 { + unpackB := len(options.OCISpecPlatform) == 1 + if options.Unpack != nil { + unpackB = *options.Unpack + if unpackB && len(options.OCISpecPlatform) != 1 { return nil, fmt.Errorf("unpacking requires a single platform to be specified (e.g., --platform=amd64)") } } - snOpt := getSnapshotterOpts(snapshotter) + snOpt := getSnapshotterOpts(options.GOptions.Snapshotter) if unpackB { - logrus.Debugf("The image will be unpacked for platform %q, snapshotter %q.", ocispecPlatforms[0], snapshotter) + log.G(ctx).Debugf("The image will be unpacked for platform %q, snapshotter %q.", options.OCISpecPlatform[0], options.GOptions.Snapshotter) imgcryptPayload := imgcrypt.Payload{} imgcryptUnpackOpt := encryption.WithUnpackConfigApplyOpts(encryption.WithDecryptedUnpack(&imgcryptPayload)) config.RemoteOpts = append(config.RemoteOpts, @@ -230,9 +231,9 @@ func PullImage(ctx context.Context, client *containerd.Client, stdout, stderr io containerd.WithUnpackOpts([]containerd.UnpackOpt{imgcryptUnpackOpt})) // different remote snapshotters will update pull.Config separately - snOpt.apply(config, ref) + snOpt.apply(config, ref, options.RFlags) } else { - logrus.Debugf("The image will not be unpacked. Platforms=%v.", ocispecPlatforms) + log.G(ctx).Debugf("The image will not be unpacked. Platforms=%v.", options.OCISpecPlatform) } containerdImage, err = pull.Pull(ctx, client, ref, config) @@ -247,7 +248,7 @@ func PullImage(ctx context.Context, client *containerd.Client, stdout, stderr io Ref: ref, Image: containerdImage, ImageConfig: *imgConfig, - Snapshotter: snapshotter, + Snapshotter: options.GOptions.Snapshotter, Remote: snOpt.isRemote(), } return res, nil @@ -358,45 +359,47 @@ func ReadImageConfig(ctx context.Context, img containerd.Image) (ocispec.Image, // ParseRepoTag parses raw `imgName` to repository and tag. func ParseRepoTag(imgName string) (string, string) { - logrus.Debugf("raw image name=%q", imgName) + log.L.Debugf("raw image name=%q", imgName) - ref, err := refdocker.ParseDockerRef(imgName) + parsedReference, err := referenceutil.Parse(imgName) if err != nil { - logrus.WithError(err).Debugf("unparsable image name %q", imgName) + log.L.WithError(err).Debugf("unparsable image name %q", imgName) return "", "" } - var tag string - - if tagged, ok := ref.(refdocker.Tagged); ok { - tag = tagged.Tag() - } - repository := refdocker.FamiliarName(ref) - - return repository, tag + return parsedReference.FamiliarName(), parsedReference.Tag } -type snapshotKey string - -// recursive function to calculate total usage of key's parent -func (key snapshotKey) add(ctx context.Context, s snapshots.Snapshotter, usage *snapshots.Usage) error { - if key == "" { - return nil - } - u, err := s.Usage(ctx, string(key)) - if err != nil { - return err - } - - usage.Add(u) +// ResourceUsage will return: +// - the Usage value of the resource referenced by ID +// - the cumulative Usage value of the resource, and all parents, recursively +// Typically, for a running container, this will equal the size of the read-write layer, plus the sum of the size of all layers in the base image +func ResourceUsage(ctx context.Context, snapshotter snapshots.Snapshotter, resourceID string) (snapshots.Usage, snapshots.Usage, error) { + first := snapshots.Usage{} + total := snapshots.Usage{} + var info snapshots.Info + for next := resourceID; next != ""; next = info.Parent { + // Get the resource usage info + usage, err := snapshotter.Usage(ctx, next) + if err != nil { + return first, total, err + } + // In case that's the first one, store that + if next == resourceID { + first = usage + } + // And increment totals + total.Size += usage.Size + total.Inodes += usage.Inodes - info, err := s.Stat(ctx, string(key)) - if err != nil { - return err + // Now, get the parent, if any and iterate + info, err = snapshotter.Stat(ctx, next) + if err != nil { + return first, total, err + } } - key = snapshotKey(info.Parent) - return key.add(ctx, s, usage) + return first, total, nil } // UnpackedImageSize is the size of the unpacked snapshots. @@ -408,23 +411,56 @@ func UnpackedImageSize(ctx context.Context, s snapshots.Snapshotter, img contain } chainID := identity.ChainID(diffIDs).String() - usage, err := s.Usage(ctx, chainID) + _, total, err := ResourceUsage(ctx, s, chainID) + + return total.Size, err +} + +// GetUnusedImages returns the list of all images which are not referenced by a container. +func GetUnusedImages(ctx context.Context, client *containerd.Client, filters ...Filter) ([]images.Image, error) { + var ( + imageStore = client.ImageService() + containerStore = client.ContainerService() + ) + + containers, err := containerStore.List(ctx) if err != nil { - if errdefs.IsNotFound(err) { - logrus.WithError(err).Debugf("image %q seems not unpacked", img.Name()) - return 0, nil - } - return 0, err + return []images.Image{}, err + } + + usedImages := make(map[string]struct{}) + for _, container := range containers { + usedImages[container.Image] = struct{}{} } - info, err := s.Stat(ctx, chainID) + allImages, err := imageStore.List(ctx) if err != nil { - return 0, err + return []images.Image{}, err } - //add ChainID's parent usage to the total usage - if err := snapshotKey(info.Parent).add(ctx, s, &usage); err != nil { - return 0, err + unusedImages := make([]images.Image, 0, len(allImages)) + for _, image := range allImages { + if _, ok := usedImages[image.Name]; ok { + continue + } + unusedImages = append(unusedImages, image) } - return usage.Size, nil + + return ApplyFilters(unusedImages, filters...) +} + +// GetDanglingImages returns the list of all images which are not tagged. +func GetDanglingImages(ctx context.Context, client *containerd.Client, filters ...Filter) ([]images.Image, error) { + var ( + imageStore = client.ImageService() + ) + + allImages, err := imageStore.List(ctx) + if err != nil { + return []images.Image{}, err + } + + filters = append([]Filter{FilterDanglingImages()}, filters...) + + return ApplyFilters(allImages, filters...) } diff --git a/pkg/imgutil/jobs/jobs.go b/pkg/imgutil/jobs/jobs.go index da5da32c4ef..7dc0d985828 100644 --- a/pkg/imgutil/jobs/jobs.go +++ b/pkg/imgutil/jobs/jobs.go @@ -24,13 +24,14 @@ import ( "text/tabwriter" "time" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/log" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/containerd/remotes" "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/remotes" + "github.com/containerd/containerd/v2/pkg/progress" + "github.com/containerd/errdefs" + "github.com/containerd/log" ) // ShowProgress continuously updates the output with job progress @@ -101,11 +102,10 @@ outer: if !errdefs.IsNotFound(err) { log.G(ctx).WithError(err).Error("failed to get content info") continue outer - } else { - statuses[key] = StatusInfo{ - Ref: key, - Status: StatusWaiting, - } + } + statuses[key] = StatusInfo{ + Ref: key, + Status: StatusWaiting, } } else if info.CreatedAt.After(start) { statuses[key] = StatusInfo{ diff --git a/pkg/imgutil/load/load.go b/pkg/imgutil/load/load.go new file mode 100644 index 00000000000..0afb322f4e4 --- /dev/null +++ b/pkg/imgutil/load/load.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 load + +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/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/platformutil" +) + +// FromArchive loads and unpacks the images from the tar archive specified in image load options. +func FromArchive(ctx context.Context, client *containerd.Client, options types.ImageLoadOptions) ([]images.Image, error) { + if options.Input != "" { + f, err := os.Open(options.Input) + if err != nil { + return nil, err + } + defer f.Close() + options.Stdin = f + } else { + // check if stdin is empty. + stdinStat, err := os.Stdin.Stat() + if err != nil { + return nil, err + } + if stdinStat.Size() == 0 && (stdinStat.Mode()&os.ModeNamedPipe) == 0 { + return nil, errors.New("stdin is empty and input flag is not specified") + } + } + decompressor, err := compression.DecompressStream(options.Stdin) + if err != nil { + return nil, err + } + platMC, err := platformutil.NewMatchComparer(options.AllPlatforms, options.Platform) + if err != nil { + return nil, err + } + imgs, err := importImages(ctx, client, decompressor, options.GOptions.Snapshotter, platMC) + if err != nil { + return nil, err + } + unpackedImages := make([]images.Image, 0, len(imgs)) + for _, img := range imgs { + err := unpackImage(ctx, client, img, platMC, options) + if err != nil { + return unpackedImages, fmt.Errorf("error unpacking image (%s): %w", img.Name, err) + } + unpackedImages = append(unpackedImages, img) + } + return unpackedImages, nil +} + +// FromOCIArchive loads and unpacks the images from the OCI formatted archive at the provided file system path. +func FromOCIArchive(ctx context.Context, client *containerd.Client, pathToOCIArchive string, options types.ImageLoadOptions) ([]images.Image, error) { + const ociArchivePrefix = "oci-archive://" + pathToOCIArchive = strings.TrimPrefix(pathToOCIArchive, ociArchivePrefix) + + const separator = ":" + if strings.Contains(pathToOCIArchive, separator) { + subs := strings.Split(pathToOCIArchive, separator) + if len(subs) != 2 { + return nil, errors.New("too many seperators found in oci-archive path") + } + pathToOCIArchive = subs[0] + } + + options.Input = pathToOCIArchive + + 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/pull/pull.go b/pkg/imgutil/pull/pull.go index 11a257684c8..e677e9cd732 100644 --- a/pkg/imgutil/pull/pull.go +++ b/pkg/imgutil/pull/pull.go @@ -21,13 +21,15 @@ import ( "context" "io" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/log" - "github.com/containerd/containerd/remotes" - "github.com/containerd/nerdctl/pkg/imgutil/jobs" - "github.com/containerd/nerdctl/pkg/platformutil" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/jobs" + "github.com/containerd/nerdctl/v2/pkg/platformutil" ) // Config for content fetch diff --git a/pkg/imgutil/push/push.go b/pkg/imgutil/push/push.go index d16b4b613f7..94c6acca71c 100644 --- a/pkg/imgutil/push/push.go +++ b/pkg/imgutil/push/push.go @@ -25,22 +25,22 @@ import ( "text/tabwriter" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/log" - "github.com/containerd/containerd/pkg/progress" - "github.com/containerd/containerd/platforms" - "github.com/containerd/containerd/remotes" - "github.com/containerd/containerd/remotes/docker" - "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" - "github.com/containerd/nerdctl/pkg/imgutil/jobs" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "golang.org/x/sync/errgroup" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/containerd/v2/core/remotes/docker" + "github.com/containerd/containerd/v2/pkg/progress" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/imgutil/jobs" ) // Push pushes an image to a remote registry. -func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resolver, stdout io.Writer, +func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resolver, pushTracker docker.StatusTracker, stdout io.Writer, localRef, remoteRef string, platform platforms.MatchComparer, allowNonDist, quiet bool) error { img, err := client.ImageService().Get(ctx, localRef) if err != nil { @@ -48,7 +48,7 @@ func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resol } desc := img.Target - ongoing := newPushJobs(dockerconfigresolver.PushTracker) + ongoing := newPushJobs(pushTracker) eg, ctx := errgroup.WithContext(ctx) diff --git a/pkg/imgutil/snapshotter.go b/pkg/imgutil/snapshotter.go index 0655c4774ce..15f63e7c00e 100644 --- a/pkg/imgutil/snapshotter.go +++ b/pkg/imgutil/snapshotter.go @@ -19,18 +19,23 @@ package imgutil import ( "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - ctdsnapshotters "github.com/containerd/containerd/pkg/snapshotters" - "github.com/containerd/nerdctl/pkg/imgutil/pull" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + ctdsnapshotters "github.com/containerd/containerd/v2/pkg/snapshotters" + "github.com/containerd/log" "github.com/containerd/stargz-snapshotter/fs/source" - "github.com/sirupsen/logrus" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil/pull" + "github.com/containerd/nerdctl/v2/pkg/snapshotterutil" ) const ( snapshotterNameOverlaybd = "overlaybd" snapshotterNameStargz = "stargz" snapshotterNameNydus = "nydus" + snapshotterNameSoci = "soci" + snapshotterNameCvmfs = "cvmfs-snapshotter" // prefetch size for stargz prefetchSize = 10 * 1024 * 1024 @@ -41,12 +46,14 @@ var builtinRemoteSnapshotterOpts = map[string]snapshotterOpts{ snapshotterNameOverlaybd: &remoteSnapshotterOpts{snapshotter: "overlaybd"}, snapshotterNameStargz: &remoteSnapshotterOpts{snapshotter: "stargz", extraLabels: stargzExtraLabels}, snapshotterNameNydus: &remoteSnapshotterOpts{snapshotter: "nydus"}, + snapshotterNameSoci: &remoteSnapshotterOpts{snapshotter: "soci", extraLabels: sociExtraLabels}, + snapshotterNameCvmfs: &remoteSnapshotterOpts{snapshotter: "cvmfs-snapshotter"}, } // snapshotterOpts is used to update pull config // for different snapshotters type snapshotterOpts interface { - apply(config *pull.Config, ref string) + apply(config *pull.Config, ref string, rFlags types.RemoteSnapshotterFlags) isRemote() bool } @@ -55,7 +62,7 @@ func getSnapshotterOpts(snapshotter string) snapshotterOpts { for sn, sno := range builtinRemoteSnapshotterOpts { if strings.Contains(snapshotter, sn) { if snapshotter != sn { - logrus.Debugf("assuming %s to be a %s-compatible snapshotter", snapshotter, sn) + log.L.Debugf("assuming %s to be a %s-compatible snapshotter", snapshotter, sn) } return sno } @@ -68,17 +75,17 @@ func getSnapshotterOpts(snapshotter string) snapshotterOpts { // interface `snapshotterOpts.isRemote()` function type remoteSnapshotterOpts struct { snapshotter string - extraLabels func(func(images.Handler) images.Handler) func(images.Handler) images.Handler + extraLabels func(func(images.Handler) images.Handler, types.RemoteSnapshotterFlags) func(images.Handler) images.Handler } func (rs *remoteSnapshotterOpts) isRemote() bool { return true } -func (rs *remoteSnapshotterOpts) apply(config *pull.Config, ref string) { +func (rs *remoteSnapshotterOpts) apply(config *pull.Config, ref string, rFlags types.RemoteSnapshotterFlags) { h := ctdsnapshotters.AppendInfoHandlerWrapper(ref) if rs.extraLabels != nil { - h = rs.extraLabels(h) + h = rs.extraLabels(h, rFlags) } config.RemoteOpts = append( config.RemoteOpts, @@ -93,7 +100,7 @@ type defaultSnapshotterOpts struct { snapshotter string } -func (dsn *defaultSnapshotterOpts) apply(config *pull.Config, _ref string) { +func (dsn *defaultSnapshotterOpts) apply(config *pull.Config, _ref string, rFlags types.RemoteSnapshotterFlags) { config.RemoteOpts = append( config.RemoteOpts, containerd.WithPullSnapshotter(dsn.snapshotter)) @@ -104,6 +111,10 @@ func (dsn *defaultSnapshotterOpts) isRemote() bool { return false } -func stargzExtraLabels(f func(images.Handler) images.Handler) func(images.Handler) images.Handler { +func stargzExtraLabels(f func(images.Handler) images.Handler, rFlags types.RemoteSnapshotterFlags) func(images.Handler) images.Handler { return source.AppendExtraLabelsHandler(prefetchSize, f) } + +func sociExtraLabels(f func(images.Handler) images.Handler, rFlags types.RemoteSnapshotterFlags) func(images.Handler) images.Handler { + return snapshotterutil.SociAppendDefaultLabelsHandlerWrapper(rFlags.SociIndexDigest, f) +} diff --git a/pkg/imgutil/snapshotter_test.go b/pkg/imgutil/snapshotter_test.go index 975d421f811..6acc6951ed3 100644 --- a/pkg/imgutil/snapshotter_test.go +++ b/pkg/imgutil/snapshotter_test.go @@ -21,12 +21,15 @@ import ( "reflect" "testing" - "github.com/containerd/containerd" - ctdsnapshotters "github.com/containerd/containerd/pkg/snapshotters" - "github.com/containerd/nerdctl/pkg/imgutil/pull" digest "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" + + containerd "github.com/containerd/containerd/v2/client" + ctdsnapshotters "github.com/containerd/containerd/v2/pkg/snapshotters" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/imgutil/pull" ) const ( @@ -52,6 +55,10 @@ func TestGetSnapshotterOpts(t *testing.T) { sns: []string{"stargz", "stargz-v1"}, check: remoteSnOpts("stargz", true), }, + { + sns: []string{"soci"}, + check: remoteSnOpts("soci", true), + }, { sns: []string{"overlaybd", "overlaybd-v2"}, check: sameOpts(&remoteSnapshotterOpts{snapshotter: "overlaybd"}), @@ -89,7 +96,8 @@ func sameOpts(want snapshotterOpts) func(*testing.T, snapshotterOpts) { func getAndApplyRemoteOpts(t *testing.T, sn string) *containerd.RemoteContext { config := &pull.Config{} snOpts := getSnapshotterOpts(sn) - snOpts.apply(config, testRef) + rFlags := types.RemoteSnapshotterFlags{} + snOpts.apply(config, testRef, rFlags) rc := &containerd.RemoteContext{} for _, o := range config.RemoteOpts { @@ -132,6 +140,12 @@ func TestRemoteSnapshotterOpts(t *testing.T) { checkRemoteSnapshotterAnnotataions, checkStargzSnapshotterAnnotataions, }, }, + { + name: "soci", + check: []func(t *testing.T, a map[string]string){ + checkRemoteSnapshotterAnnotataions, checkSociSnapshotterAnnotataions, + }, + }, { name: "nydus", check: []func(t *testing.T, a map[string]string){checkRemoteSnapshotterAnnotataions}, @@ -175,3 +189,16 @@ func checkStargzSnapshotterAnnotataions(t *testing.T, a map[string]string) { _, ok := a["containerd.io/snapshot/remote/urls"] assert.Equal(t, ok, true) } + +// using values from soci source to check for annotations ( +// see https://github.com/awslabs/soci-snapshotter/blob/b05ba712d246ecc5146469f87e5e9305702fd72b/fs/source/source.go#L80C1-L80C6 +func checkSociSnapshotterAnnotataions(t *testing.T, a map[string]string) { + assert.Check(t, a != nil) + _, ok := a["containerd.io/snapshot/remote/soci.size"] + assert.Equal(t, ok, true) + _, ok = a["containerd.io/snapshot/remote/image.layers.size"] + assert.Equal(t, ok, true) + _, ok = a["containerd.io/snapshot/remote/soci.index.digest"] + assert.Equal(t, ok, true) + +} diff --git a/pkg/infoutil/infoutil.go b/pkg/infoutil/infoutil.go index c3eec405948..43041dd5d1b 100644 --- a/pkg/infoutil/infoutil.go +++ b/pkg/infoutil/infoutil.go @@ -26,24 +26,25 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/containerd/containerd" - ptypes "github.com/containerd/containerd/protobuf/types" - "github.com/containerd/containerd/services/introspection" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/logging" - "github.com/containerd/nerdctl/pkg/version" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/introspection" + ptypes "github.com/containerd/containerd/v2/pkg/protobuf/types" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/version" ) func NativeDaemonInfo(ctx context.Context, client *containerd.Client) (*native.DaemonInfo, error) { introService := client.IntrospectionService() - plugins, err := introService.Plugins(ctx, nil) + plugins, err := introService.Plugins(ctx) if err != nil { return nil, err } - server, err := introService.Server(ctx, &ptypes.Empty{}) + server, err := introService.Server(ctx) if err != nil { return nil, err } @@ -67,7 +68,7 @@ func Info(ctx context.Context, client *containerd.Client, snapshotter, cgroupMan return nil, err } introService := client.IntrospectionService() - daemonIntro, err := introService.Server(ctx, &ptypes.Empty{}) + daemonIntro, err := introService.Server(ctx) if err != nil { return nil, err } @@ -80,7 +81,6 @@ func Info(ctx context.Context, client *containerd.Client, snapshotter, cgroupMan info.ID = daemonIntro.UUID // Storage drivers and logging drivers are not really Server concept for nerdctl, but mimics `docker info` output info.Driver = snapshotter - info.Plugins.Log = logging.Drivers() info.Plugins.Storage = snapshotterPlugins info.SystemTime = time.Now().Format(time.RFC3339Nano) info.LoggingDriver = "json-file" // hard-coded @@ -101,7 +101,7 @@ func Info(ctx context.Context, client *containerd.Client, snapshotter, cgroupMan func GetSnapshotterNames(ctx context.Context, introService introspection.Service) ([]string, error) { var names []string - plugins, err := introService.Plugins(ctx, nil) + plugins, err := introService.Plugins(ctx) if err != nil { return nil, err } @@ -115,8 +115,8 @@ func GetSnapshotterNames(ctx context.Context, introService introspection.Service func ClientVersion() dockercompat.ClientVersion { return dockercompat.ClientVersion{ - Version: version.Version, - GitCommit: version.Revision, + Version: version.GetVersion(), + GitCommit: version.GetRevision(), GoVersion: runtime.Version(), Os: runtime.GOOS, Arch: runtime.GOARCH, @@ -160,19 +160,19 @@ func ServerSemVer(ctx context.Context, client *containerd.Client) (*semver.Versi func buildctlVersion() dockercompat.ComponentVersion { buildctlBinary, err := buildkitutil.BuildctlBinary() if err != nil { - logrus.Warnf("unable to determine buildctl version: %s", err.Error()) + log.L.Warnf("unable to determine buildctl version: %s", err.Error()) return dockercompat.ComponentVersion{Name: "buildctl"} } stdout, err := exec.Command(buildctlBinary, "--version").Output() if err != nil { - logrus.Warnf("unable to determine buildctl version: %s", err.Error()) + log.L.Warnf("unable to determine buildctl version: %s", err.Error()) return dockercompat.ComponentVersion{Name: "buildctl"} } v, err := parseBuildctlVersion(stdout) if err != nil { - logrus.Warn(err) + log.L.Warn(err) return dockercompat.ComponentVersion{Name: "buildctl"} } return *v @@ -205,12 +205,12 @@ func parseBuildctlVersion(buildctlVersionStdout []byte) (*dockercompat.Component func runcVersion() dockercompat.ComponentVersion { stdout, err := exec.Command("runc", "--version").Output() if err != nil { - logrus.Warnf("unable to determine runc version: %s", err.Error()) + log.L.Warnf("unable to determine runc version: %s", err.Error()) return dockercompat.ComponentVersion{Name: "runc"} } v, err := parseRuncVersion(stdout) if err != nil { - logrus.Warn(err) + log.L.Warn(err) return dockercompat.ComponentVersion{Name: "runc"} } return *v @@ -228,7 +228,7 @@ func parseRuncVersion(runcVersionStdout []byte) (*dockercompat.ComponentVersion, for _, detailsLine := range versionList[1:] { detail := strings.SplitN(detailsLine, ":", 2) if len(detail) != 2 { - logrus.Warnf("unable to determine one of runc details, got: %s, %d", detail, len(detail)) + log.L.Warnf("unable to determine one of runc details, got: %s, %d", detail, len(detail)) continue } switch strings.TrimSpace(detail[0]) { diff --git a/pkg/infoutil/infoutil_freebsd.go b/pkg/infoutil/infoutil_freebsd.go index ca8d67c6eaa..b47c63df524 100644 --- a/pkg/infoutil/infoutil_freebsd.go +++ b/pkg/infoutil/infoutil_freebsd.go @@ -17,8 +17,8 @@ package infoutil import ( - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/docker/docker/pkg/sysinfo" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/sysinfo" ) const UnameO = "FreeBSD" diff --git a/pkg/infoutil/infoutil_linux.go b/pkg/infoutil/infoutil_linux.go index f7f7ef0d81b..93d22ee941c 100644 --- a/pkg/infoutil/infoutil_linux.go +++ b/pkg/infoutil/infoutil_linux.go @@ -20,13 +20,15 @@ import ( "fmt" "strings" - "github.com/containerd/cgroups/v3" - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/rootlessutil" "github.com/docker/docker/pkg/meminfo" - "github.com/docker/docker/pkg/sysinfo" + + "github.com/containerd/cgroups/v3" + + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/sysinfo" ) const UnameO = "GNU/Linux" @@ -49,7 +51,7 @@ WARNING: AppArmor profile %q is not loaded. This warning is negligible if you do not intend to use AppArmor.`), defaults.AppArmorProfileName)) } } - info.SecurityOptions = append(info.SecurityOptions, "name=seccomp,profile=default") + info.SecurityOptions = append(info.SecurityOptions, "name=seccomp,profile="+defaults.SeccompProfileName) if defaults.CgroupnsMode() == "private" { info.SecurityOptions = append(info.SecurityOptions, "name=cgroupns") } diff --git a/pkg/infoutil/infoutil_test.go b/pkg/infoutil/infoutil_test.go index 632e9dd03c6..33cbe6cc0b4 100644 --- a/pkg/infoutil/infoutil_test.go +++ b/pkg/infoutil/infoutil_test.go @@ -19,8 +19,9 @@ package infoutil import ( "testing" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" ) func TestParseBuildctlVersion(t *testing.T) { diff --git a/pkg/infoutil/infoutil_unix.go b/pkg/infoutil/infoutil_unix.go index e49f27e5e05..1a4068a067a 100644 --- a/pkg/infoutil/infoutil_unix.go +++ b/pkg/infoutil/infoutil_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -23,7 +23,6 @@ import ( "io" "os" "regexp" - "strings" "golang.org/x/sys/unix" diff --git a/pkg/infoutil/infoutil_unix_test.go b/pkg/infoutil/infoutil_unix_test.go index 47067a8d1fa..208aedbafd3 100644 --- a/pkg/infoutil/infoutil_unix_test.go +++ b/pkg/infoutil/infoutil_unix_test.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. diff --git a/pkg/infoutil/infoutil_windows.go b/pkg/infoutil/infoutil_windows.go index 3131731eb3a..a8075078605 100644 --- a/pkg/infoutil/infoutil_windows.go +++ b/pkg/infoutil/infoutil_windows.go @@ -17,33 +17,211 @@ package infoutil import ( - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/docker/docker/pkg/sysinfo" + "fmt" + "runtime" + "strings" + + "github.com/docker/docker/pkg/meminfo" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/sysinfo" +) + +const UnameO = "Microsoft Windows" + +// MsiNTProductType is the product type of the operating system. +// https://learn.microsoft.com/en-us/windows/win32/msi/msintproducttype +// Ref: https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa +const ( + verNTServer = 0x0000003 ) -// UnameR returns `uname -r` +type windowsInfoUtil interface { + RtlGetVersion() *windows.OsVersionInfoEx + GetRegistryStringValue(key registry.Key, path string, name string) (string, error) + GetRegistryIntValue(key registry.Key, path string, name string) (int, error) +} + +type winInfoUtil struct{} + +// RtlGetVersion implements the RtlGetVersion method using the actual windows package +func (sw *winInfoUtil) RtlGetVersion() *windows.OsVersionInfoEx { + return windows.RtlGetVersion() +} + +// UnameR returns the Kernel version func UnameR() string { - return "" + util := &winInfoUtil{} + version, err := getKernelVersion(util) + if err != nil { + log.L.Error(err.Error()) + } + + return version } -// UnameM returns `uname -m` +// UnameM returns the architecture of the system func UnameM() string { - return "" + arch := runtime.GOARCH + + if strings.ToLower(arch) == "amd64" { + return "x86_64" + } + + // "386": 32-bit Intel/AMD processors (x86 architecture) + if strings.ToLower(arch) == "386" { + return "x86" + } + + // arm, s390x, and so on + return arch } +// DistroName returns version information about the currently running operating system func DistroName() string { - return "" + util := &winInfoUtil{} + version, err := distroName(util) + if err != nil { + log.L.Error(err.Error()) + } + + return version +} + +func distroName(sw windowsInfoUtil) (string, error) { + // Get the OS version information from the Windows registry + regPath := `SOFTWARE\Microsoft\Windows NT\CurrentVersion` + + // Eg. 22631 (REG_SZ) + currBuildNo, err := sw.GetRegistryStringValue(registry.LOCAL_MACHINE, regPath, "CurrentBuildNumber") + if err != nil { + return "", fmt.Errorf("failed to get os version (build number) %v", err) + } + + // Eg. 23H2 (REG_SZ) + displayVersion, err := sw.GetRegistryStringValue(registry.LOCAL_MACHINE, regPath, "DisplayVersion") + if err != nil { + return "", fmt.Errorf("failed to get os version (display version) %v", err) + } + + // UBR: Update Build Revision. Eg. 3737 (REG_DWORD 32-bit Value) + ubr, err := sw.GetRegistryIntValue(registry.LOCAL_MACHINE, regPath, "UBR") + if err != nil { + return "", fmt.Errorf("failed to get os version (ubr) %v", err) + } + + productType := "" + if isWindowsServer(sw) { + productType = "Server" + } + + // Concatenate the reg.key values to get the OS version information + // Example: "Microsoft Windows Version 23H2 (OS Build 22631.3737)" + versionString := fmt.Sprintf("%s %s Version %s (OS Build %s.%d)", + UnameO, + productType, + displayVersion, + currBuildNo, + ubr, + ) + + // Replace double spaces with single spaces + versionString = strings.ReplaceAll(versionString, " ", " ") + + return versionString, nil +} + +func getKernelVersion(sw windowsInfoUtil) (string, error) { + // Get BuildLabEx value from the Windows registry + // [buiild number].[revision number].[architecture].[branch].[date]-[time] + // Eg. "BuildLabEx: 10240.16412.amd64fre.th1.150729-1800" + buildLab, err := sw.GetRegistryStringValue(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, "BuildLabEx") + if err != nil { + return "", err + } + + // Get Version: Contains the major and minor version numbers of the operating system. + // Eg. "10.0" + osvi := sw.RtlGetVersion() + + // Concatenate the OS version and BuildLabEx values to get the Kernel version information + // Example: "10.0 22631 (10240.16412.amd64fre.th1.150729-1800)" + version := fmt.Sprintf("%d.%d %d (%s)", osvi.MajorVersion, osvi.MinorVersion, osvi.BuildNumber, buildLab) + return version, nil } +// GetRegistryStringValue retrieves a string value from the Windows registry +func (sw *winInfoUtil) GetRegistryStringValue(key registry.Key, path string, name string) (string, error) { + k, err := registry.OpenKey(key, path, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + + v, _, err := k.GetStringValue(name) + if err != nil { + return "", err + } + return v, nil +} + +// GetRegistryIntValue retrieves an integer value from the Windows registry +func (sw *winInfoUtil) GetRegistryIntValue(key registry.Key, path string, name string) (int, error) { + k, err := registry.OpenKey(key, path, registry.QUERY_VALUE) + if err != nil { + return 0, err + } + defer k.Close() + + v, _, err := k.GetIntegerValue(name) + if err != nil { + return 0, err + } + return int(v), nil +} + +func isWindowsServer(sw windowsInfoUtil) bool { + osvi := sw.RtlGetVersion() + return osvi.ProductType == verNTServer +} + +// Cgroups not supported on Windows func CgroupsVersion() string { return "" } func fulfillPlatformInfo(info *dockercompat.Info) { - // unimplemented + mobySysInfo := mobySysInfo(info) + + // NOTE: cgroup fields are not available on Windows + // https://techcommunity.microsoft.com/t5/containers/introducing-the-host-compute-service-hcs/ba-p/382332 + + info.IPv4Forwarding = !mobySysInfo.IPv4ForwardingDisabled + if !info.IPv4Forwarding { + info.Warnings = append(info.Warnings, "WARNING: IPv4 forwarding is disabled") + } + info.BridgeNfIptables = !mobySysInfo.BridgeNFCallIPTablesDisabled + if !info.BridgeNfIptables { + info.Warnings = append(info.Warnings, "WARNING: bridge-nf-call-iptables is disabled") + } + info.BridgeNfIP6tables = !mobySysInfo.BridgeNFCallIP6TablesDisabled + if !info.BridgeNfIP6tables { + info.Warnings = append(info.Warnings, "WARNING: bridge-nf-call-ip6tables is disabled") + } + info.NCPU = sysinfo.NumCPU() + memLimit, err := meminfo.Read() + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("failed to read mem info: %v", err)) + } else { + info.MemTotal = memLimit.MemTotal + } } -func mobySysInfo(info *dockercompat.Info) *sysinfo.SysInfo { +func mobySysInfo(_ *dockercompat.Info) *sysinfo.SysInfo { var sysinfo sysinfo.SysInfo return &sysinfo } diff --git a/pkg/infoutil/infoutil_windows_test.go b/pkg/infoutil/infoutil_windows_test.go new file mode 100644 index 00000000000..173cf1927e4 --- /dev/null +++ b/pkg/infoutil/infoutil_windows_test.go @@ -0,0 +1,201 @@ +/* + 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 infoutil + +import ( + "testing" + + "go.uber.org/mock/gomock" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + "gotest.tools/v3/assert" + + mocks "github.com/containerd/nerdctl/v2/pkg/infoutil/infoutilmock" +) + +func setUpMocks(t *testing.T) *mocks.MockWindowsInfoUtil { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockInfoUtil := mocks.NewMockWindowsInfoUtil(ctrl) + + // Mock registry value: CurrentBuildNumber + mockInfoUtil. + EXPECT(). + GetRegistryStringValue(gomock.Any(), gomock.Any(), "CurrentBuildNumber"). + Return("19041", nil). + AnyTimes() + + // Mock registry value: DisplayVersion + mockInfoUtil. + EXPECT(). + GetRegistryStringValue(gomock.Any(), gomock.Any(), "DisplayVersion"). + Return("22H4", nil). + AnyTimes() + + // Mock registry value: UBR + mockInfoUtil. + EXPECT(). + GetRegistryIntValue(gomock.Any(), gomock.Any(), "UBR"). + Return(558, nil). + AnyTimes() + + return mockInfoUtil +} + +const ( + verNTWorkStation = 0x0000001 + verNTDomainController = 0x0000002 +) + +func TestDistroName(t *testing.T) { + mockInfoUtil := setUpMocks(t) + + baseVersion := windows.OsVersionInfoEx{ + MajorVersion: 10, + MinorVersion: 0, + BuildNumber: 19041, + } + + tests := []struct { + productType byte + expected string + }{ + { + productType: verNTWorkStation, + expected: "Microsoft Windows Version 22H4 (OS Build 19041.558)", + }, + { + productType: verNTServer, + expected: "Microsoft Windows Server Version 22H4 (OS Build 19041.558)", + }, + } + + for _, tt := range tests { + // Mock sys/windows RtlGetVersion + osvi := baseVersion + osvi.ProductType = tt.productType + mockInfoUtil.EXPECT().RtlGetVersion().Return(&osvi).Times(1) + + t.Run(tt.expected, func(t *testing.T) { + actual, err := distroName(mockInfoUtil) + assert.Equal(t, tt.expected, actual, "distroName should return the name of the operating system") + assert.NilError(t, err) + }) + } +} + +func TestDistroNameError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockInfoUtil := mocks.NewMockWindowsInfoUtil(ctrl) + + mockInfoUtil.EXPECT().RtlGetVersion().Return(nil).Times(0) + mockInfoUtil. + EXPECT(). + GetRegistryStringValue(gomock.Any(), gomock.Any(), gomock.Any()). + Return("19041", registry.ErrNotExist).AnyTimes() + + actual, err := distroName(mockInfoUtil) + assert.ErrorContains(t, err, registry.ErrNotExist.Error(), "distroName should return an error on error") + assert.Equal(t, "", actual, "distroname should return an empty string on error") +} + +func TestGetKernelVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockInfoUtil := mocks.NewMockWindowsInfoUtil(ctrl) + + // Mock registry value: BuildLabEx + mockInfoUtil. + EXPECT(). + GetRegistryStringValue(gomock.Any(), gomock.Any(), "BuildLabEx"). + Return("10240.16412.amd64fre.th1.150729-1800", nil). + Times(1) + + baseVersion := windows.OsVersionInfoEx{ + MajorVersion: 10, + MinorVersion: 0, + BuildNumber: 19041, + } + + expected := "10.0 19041 (10240.16412.amd64fre.th1.150729-1800)" + + // Mock sys/windows RtlGetVersion + osvi := baseVersion + mockInfoUtil.EXPECT().RtlGetVersion().Return(&osvi).Times(1) + + actual, err := getKernelVersion(mockInfoUtil) + assert.NilError(t, err) + assert.Equal(t, expected, actual, "getKernelVersion should return the kernel version") +} + +func TestGetKernelVersionError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockInfoUtil := mocks.NewMockWindowsInfoUtil(ctrl) + + mockInfoUtil.EXPECT().RtlGetVersion().Return(nil).Times(0) + mockInfoUtil. + EXPECT(). + GetRegistryStringValue(gomock.Any(), gomock.Any(), gomock.Any()). + Return("", registry.ErrNotExist).Times(1) + + actual, err := getKernelVersion(mockInfoUtil) + assert.ErrorContains(t, err, registry.ErrNotExist.Error(), "getKernelVersion should return an error on error") + assert.Equal(t, "", actual, "getKernelVersion should return an empty string on error") +} + +func TestIsWindowsServer(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tests := []struct { + productType string + osvi windows.OsVersionInfoEx + expected bool + }{ + { + productType: "VER_NT_WORKSTATION", + osvi: windows.OsVersionInfoEx{ProductType: verNTWorkStation}, + expected: false, + }, + { + productType: "VER_NT_DOMAIN_CONTROLLER", + osvi: windows.OsVersionInfoEx{ProductType: verNTDomainController}, + expected: false, + }, + { + productType: "VER_NT_SERVER", + osvi: windows.OsVersionInfoEx{ProductType: verNTServer}, + expected: true, + }, + } + + mockSysCall := mocks.NewMockWindowsInfoUtil(ctrl) + for _, tt := range tests { + mockSysCall.EXPECT().RtlGetVersion().Return(&tt.osvi) + + t.Run(tt.productType, func(t *testing.T) { + actual := isWindowsServer(mockSysCall) + assert.Equal(t, tt.expected, actual, "isWindowsServer should return true on Windows Server") + }) + } +} diff --git a/pkg/infoutil/infoutilmock/info.util.mock.go b/pkg/infoutil/infoutilmock/info.util.mock.go new file mode 100644 index 00000000000..298597ece67 --- /dev/null +++ b/pkg/infoutil/infoutilmock/info.util.mock.go @@ -0,0 +1,108 @@ +//go:build windows + +/* + 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 infoutilmock + +import ( + "reflect" + + "go.uber.org/mock/gomock" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// MockWindowsInfoUtil is a mock of windowsInfoUtil interface +type MockWindowsInfoUtil struct { + ctrl *gomock.Controller + recorder *MockWindowsInfoUtilMockRecorder +} + +// MockWindowsInfoUtilMockRecorder is the mock recorder for MockWindowsInfoUtil +type MockWindowsInfoUtilMockRecorder struct { + mock *MockWindowsInfoUtil +} + +// NewMockWindowsInfoUtil creates a new mock instance +func NewMockWindowsInfoUtil(ctrl *gomock.Controller) *MockWindowsInfoUtil { + mock := &MockWindowsInfoUtil{ctrl: ctrl} + mock.recorder = &MockWindowsInfoUtilMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockWindowsInfoUtil) EXPECT() *MockWindowsInfoUtilMockRecorder { + return m.recorder +} + +// Create mocks the RtlGetVersion method of windowsInfoUtil +func (m *MockWindowsInfoUtil) RtlGetVersion() *windows.OsVersionInfoEx { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RtlGetVersion") + ret0, _ := ret[0].(*windows.OsVersionInfoEx) + return ret0 +} + +// Expected call of RtlGetVersion +func (m *MockWindowsInfoUtilMockRecorder) RtlGetVersion() *gomock.Call { + m.mock.ctrl.T.Helper() + return m.mock.ctrl.RecordCallWithMethodType( + m.mock, + "RtlGetVersion", + reflect.TypeOf((*MockWindowsInfoUtil)(nil).RtlGetVersion), + ) +} + +// Create mocks the GetRegistryStringValue method of windowsInfoUtil +func (m *MockWindowsInfoUtil) GetRegistryStringValue(key registry.Key, path string, name string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRegistryStringValue", key, path, name) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Expected call of GetRegistryStringValue +func (m *MockWindowsInfoUtilMockRecorder) GetRegistryStringValue(key any, path any, name any) *gomock.Call { + m.mock.ctrl.T.Helper() + return m.mock.ctrl.RecordCallWithMethodType( + m.mock, + "GetRegistryStringValue", + reflect.TypeOf((*MockWindowsInfoUtil)(nil).GetRegistryStringValue), + key, path, name, + ) +} + +// Create mocks the GetRegistryIntValue method of windowsInfoUtil +func (m *MockWindowsInfoUtil) GetRegistryIntValue(key registry.Key, path string, name string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRegistryIntValue", key, path, name) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Expected call of GetRegistryIntValue +func (m *MockWindowsInfoUtilMockRecorder) GetRegistryIntValue(key any, path any, name any) *gomock.Call { + m.mock.ctrl.T.Helper() + return m.mock.ctrl.RecordCallWithMethodType( + m.mock, + "GetRegistryIntValue", + reflect.TypeOf((*MockWindowsInfoUtil)(nil).GetRegistryIntValue), + key, path, name, + ) +} diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 843c59c705c..aceec3bca3d 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -35,40 +35,53 @@ import ( "strings" "time" - "github.com/containerd/containerd" - "github.com/containerd/containerd/runtime/restart" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/labels" "github.com/docker/go-connections/nat" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/runtime/restart" + "github.com/containerd/go-cni" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/ocihook/state" ) -// Image mimics a `docker image inspect` object. -// From https://github.com/moby/moby/blob/v20.10.1/api/types/types.go#L340-L374 +// From https://github.com/moby/moby/blob/v26.1.2/api/types/types.go#L34-L140 type Image struct { - ID string `json:"Id"` - RepoTags []string - RepoDigests []string - // TODO: Parent string - Comment string - Created string - // TODO: Container string - // TODO: ContainerConfig *container.Config - // TODO: DockerVersion string - Author string - Config *Config - Architecture string - // TODO: Variant string `json:",omitempty"` - Os string + ID string `json:"Id"` + RepoTags []string + RepoDigests []string + Parent string + Comment string + Created string + DockerVersion string + Author string + Config *Config + Architecture string + Variant string `json:",omitempty"` + Os string + // TODO: OsVersion string `json:",omitempty"` - Size int64 // Size is the unpacked size of the image - // TODO: GraphDriver GraphDriverData + + Size int64 // Size is the unpacked size of the image + VirtualSize int64 `json:"VirtualSize,omitempty"` // Deprecated + + // TODO: GraphDriver GraphDriverData + RootFS RootFS Metadata ImageMetadata + + // Deprecated: TODO: Container string + // Deprecated: TODO: ContainerConfig *container.Config +} + +// From: https://github.com/moby/moby/blob/v26.1.2/api/types/graph_driver_data.go +type GraphDriverData struct { + Data map[string]string `json:"Data"` + Name string `json:"Name"` } type RootFS struct { @@ -105,8 +118,8 @@ type Container struct { // TODO: ExecIDs []string // TODO: HostConfig *container.HostConfig // TODO: GraphDriver GraphDriverData - // TODO: SizeRw *int64 `json:",omitempty"` - // TODO: SizeRootFs *int64 `json:",omitempty"` + SizeRw *int64 `json:",omitempty"` + SizeRootFs *int64 `json:",omitempty"` Mounts []MountPoint Config *Config @@ -164,16 +177,16 @@ type ContainerState struct { Restarting bool // TODO: OOMKilled bool // TODO: Dead bool - Pid int - ExitCode int - Error string - // TODO: StartedAt string + Pid int + ExitCode int + Error string + StartedAt string FinishedAt string // TODO: Health *Health `json:",omitempty"` } type NetworkSettings struct { - Ports *nat.PortMap `json:",omitempty"` + Ports *nat.PortMap DefaultNetworkSettings Networks map[string]*NetworkEndpointSettings } @@ -211,18 +224,22 @@ type NetworkEndpointSettings struct { // ContainerFromNative instantiates a Docker-compatible Container from containerd-native Container. func ContainerFromNative(n *native.Container) (*Container, error) { + var hostname string c := &Container{ - ID: n.ID, - Created: n.CreatedAt.Format(time.RFC3339Nano), - Image: n.Image, - Name: n.Labels[labels.Name], - Driver: n.Snapshotter, + ID: n.ID, + Created: n.CreatedAt.Format(time.RFC3339Nano), + Image: n.Image, + Name: n.Labels[labels.Name], + Driver: n.Snapshotter, + // XXX is this always right? what if the container OS is NOT the same as the host OS? Platform: runtime.GOOS, // for Docker compatibility, this Platform string does NOT contain arch like "/amd64" } if n.Labels[restart.StatusLabel] == string(containerd.Running) { c.RestartCount, _ = strconv.Atoi(n.Labels[restart.CountLabel]) } + containerAnnotations := make(map[string]string) if sp, ok := n.Spec.(*specs.Spec); ok { + containerAnnotations = sp.Annotations if p := sp.Process; p != nil { if len(p.Args) > 0 { c.Path = p.Args[0] @@ -232,15 +249,24 @@ func ContainerFromNative(n *native.Container) (*Container, error) { } c.AppArmorProfile = p.ApparmorProfile } + c.Mounts = mountsFromNative(sp.Mounts) + for _, mount := range c.Mounts { + if mount.Destination == "/etc/resolv.conf" { + c.ResolvConfPath = mount.Source + } else if mount.Destination == "/etc/hostname" { + c.HostnamePath = mount.Source + } + } + hostname = sp.Hostname } if nerdctlStateDir := n.Labels[labels.StateDir]; nerdctlStateDir != "" { - c.ResolvConfPath = filepath.Join(nerdctlStateDir, "resolv.conf") - if _, err := os.Stat(c.ResolvConfPath); err != nil { - c.ResolvConfPath = "" + resolvConfPath := filepath.Join(nerdctlStateDir, "resolv.conf") + if _, err := os.Stat(resolvConfPath); err == nil { + c.ResolvConfPath = resolvConfPath } - c.HostnamePath = filepath.Join(nerdctlStateDir, "hostname") - if _, err := os.Stat(c.HostnamePath); err != nil { - c.HostnamePath = "" + hostnamePath := filepath.Join(nerdctlStateDir, "hostname") + if _, err := os.Stat(hostnamePath); err == nil { + c.HostnamePath = hostnamePath } c.LogPath = filepath.Join(nerdctlStateDir, n.ID+"-json.log") if _, err := os.Stat(c.LogPath); err != nil { @@ -265,7 +291,18 @@ func ContainerFromNative(n *native.Container) (*Container, error) { cs.Paused = n.Process.Status.Status == containerd.Paused cs.Pid = n.Process.Pid cs.ExitCode = int(n.Process.Status.ExitStatus) - cs.FinishedAt = n.Process.Status.ExitTime.Format(time.RFC3339Nano) + if containerAnnotations[labels.StateDir] != "" { + if lf, err := state.New(containerAnnotations[labels.StateDir]); err != nil { + log.L.WithError(err).Errorf("failed retrieving state") + } else if err = lf.Load(); err != nil { + log.L.WithError(err).Errorf("failed retrieving StartedAt from state") + } else if !time.Time.IsZero(lf.StartedAt) { + cs.StartedAt = lf.StartedAt.UTC().Format(time.RFC3339Nano) + } + } + if !n.Process.Status.ExitTime.IsZero() { + cs.FinishedAt = n.Process.Status.ExitTime.Format(time.RFC3339Nano) + } nSettings, err := networkSettingsFromNative(n.Process.NetNS, n.Spec.(*specs.Spec)) if err != nil { return nil, err @@ -274,56 +311,83 @@ func ContainerFromNative(n *native.Container) (*Container, error) { } c.State = cs c.Config = &Config{ - Hostname: n.Labels[labels.Hostname], - Labels: n.Labels, + Labels: n.Labels, + } + if n.Labels[labels.Hostname] != "" { + hostname = n.Labels[labels.Hostname] } + c.Config.Hostname = hostname return c, nil } -func ImageFromNative(n *native.Image) (*Image, error) { - i := &Image{} - - imgoci := n.ImageConfig +func ImageFromNative(nativeImage *native.Image) (*Image, error) { + imgOCI := nativeImage.ImageConfig + repository, tag := imgutil.ParseRepoTag(nativeImage.Image.Name) + + image := &Image{ + // Docker ID (digest of platform-specific config), not containerd ID (digest of multi-platform index or manifest) + ID: nativeImage.ImageConfigDesc.Digest.String(), + Parent: nativeImage.Image.Labels["org.mobyproject.image.parent"], + Architecture: imgOCI.Architecture, + Variant: imgOCI.Platform.Variant, + Os: imgOCI.OS, + Size: nativeImage.Size, + VirtualSize: nativeImage.Size, + RepoTags: []string{fmt.Sprintf("%s:%s", repository, tag)}, + RepoDigests: []string{fmt.Sprintf("%s@%s", repository, nativeImage.Image.Target.Digest.String())}, + } - i.RootFS.Type = imgoci.RootFS.Type - diffIDs := imgoci.RootFS.DiffIDs - for _, d := range diffIDs { - i.RootFS.Layers = append(i.RootFS.Layers, d.String()) + if len(imgOCI.History) > 0 { + image.Comment = imgOCI.History[len(imgOCI.History)-1].Comment + image.Created = imgOCI.History[len(imgOCI.History)-1].Created.Format(time.RFC3339Nano) + image.Author = imgOCI.History[len(imgOCI.History)-1].Author } - if len(imgoci.History) > 0 { - i.Comment = imgoci.History[len(imgoci.History)-1].Comment - i.Created = imgoci.History[len(imgoci.History)-1].Created.Format(time.RFC3339Nano) - i.Author = imgoci.History[len(imgoci.History)-1].Author + + image.RootFS.Type = imgOCI.RootFS.Type + for _, d := range imgOCI.RootFS.DiffIDs { + image.RootFS.Layers = append(image.RootFS.Layers, d.String()) } - i.Architecture = imgoci.Architecture - i.Os = imgoci.OS portSet := make(nat.PortSet) - for k := range imgoci.Config.ExposedPorts { + for k := range imgOCI.Config.ExposedPorts { portSet[nat.Port(k)] = struct{}{} } - i.Config = &Config{ - Cmd: imgoci.Config.Cmd, - Volumes: imgoci.Config.Volumes, - Env: imgoci.Config.Env, - User: imgoci.Config.User, - WorkingDir: imgoci.Config.WorkingDir, - Entrypoint: imgoci.Config.Entrypoint, - Labels: imgoci.Config.Labels, + image.Config = &Config{ + Cmd: imgOCI.Config.Cmd, + Volumes: imgOCI.Config.Volumes, + Env: imgOCI.Config.Env, + User: imgOCI.Config.User, + WorkingDir: imgOCI.Config.WorkingDir, + Entrypoint: imgOCI.Config.Entrypoint, + Labels: imgOCI.Config.Labels, ExposedPorts: portSet, } - i.ID = n.ImageConfigDesc.Digest.String() // Docker ID (digest of platform-specific config), not containerd ID (digest of multi-platform index or manifest) + return image, nil +} - repository, tag := imgutil.ParseRepoTag(n.Image.Name) +// mountsFromNative only filters bind mount to transform from native container. +// Because native container shows all types of mounts, such as tmpfs, proc, sysfs. +func mountsFromNative(spMounts []specs.Mount) []MountPoint { + mountpoints := make([]MountPoint, 0, len(spMounts)) + for _, m := range spMounts { + var mp MountPoint + if m.Type != "bind" { + continue + } + mp.Type = m.Type + mp.Source = m.Source + mp.Destination = m.Destination + mp.Mode = strings.Join(m.Options, ",") + mp.RW, mp.Propagation = ParseMountProperties(m.Options) + mountpoints = append(mountpoints, mp) + } - i.RepoTags = []string{fmt.Sprintf("%s:%s", repository, tag)} - i.RepoDigests = []string{fmt.Sprintf("%s@%s", repository, n.Image.Target.Digest.String())} - i.Size = n.Size - return i, nil + return mountpoints } + func statusFromNative(x containerd.Status, labels map[string]string) string { switch s := x.Status; s { case containerd.Stopped: @@ -337,12 +401,15 @@ func statusFromNative(x containerd.Status, labels map[string]string) string { } func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSettings, error) { - if n == nil { - return nil, nil - } res := &NetworkSettings{ Networks: make(map[string]*NetworkEndpointSettings), } + resPortMap := make(nat.PortMap) + res.Ports = &resPortMap + if n == nil { + return res, nil + } + var primary *NetworkEndpointSettings for _, x := range n.Interfaces { if x.Interface.Flags&net.FlagLoopback != 0 { @@ -357,7 +424,7 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting for _, a := range x.Addrs { ip, ipnet, err := net.ParseCIDR(a) if err != nil { - logrus.WithError(err).WithField("name", x.Name).Warnf("failed to parse %q", a) + log.L.WithError(err).WithField("name", x.Name).Warnf("failed to parse %q", a) continue } if ip.IsLoopback() || ip.IsLinkLocalUnicast() { @@ -377,7 +444,7 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting res.Networks[fakeDockerNetworkName] = nes if portsLabel, ok := sp.Annotations[labels.Ports]; ok { - var ports []gocni.PortMapping + var ports []cni.PortMapping err := json.Unmarshal([]byte(portsLabel), &ports) if err != nil { return nil, err @@ -386,8 +453,11 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting if err != nil { return nil, err } - res.Ports = nports + for portLabel, portBindings := range *nports { + resPortMap[portLabel] = portBindings + } } + if x.Index == n.PrimaryInterface { primary = nes } @@ -403,7 +473,7 @@ func networkSettingsFromNative(n *native.NetNS, sp *specs.Spec) (*NetworkSetting return res, nil } -func convertToNatPort(portMappings []gocni.PortMapping) (*nat.PortMap, error) { +func convertToNatPort(portMappings []cni.PortMapping) (*nat.PortMap, error) { portMap := make(nat.PortMap) for _, portMapping := range portMappings { ports := []nat.PortBinding{} @@ -442,29 +512,29 @@ type Network struct { // Scope, Driver, etc. are omitted } +type structuredCNI struct { + Name string `json:"name"` + Plugins []struct { + Ipam struct { + Ranges [][]IPAMConfig `json:"ranges"` + } `json:"ipam"` + } `json:"plugins"` +} + func NetworkFromNative(n *native.Network) (*Network, error) { var res Network - nameResult := gjson.GetBytes(n.CNI, "name") - if s, ok := nameResult.Value().(string); ok { - res.Name = s + sCNI := &structuredCNI{} + err := json.Unmarshal(n.CNI, sCNI) + if err != nil { + return nil, err } - // flatten twice to get ipamRangesResult=[{ "subnet": "10.4.19.0/24", "gateway": "10.4.19.1" }] - ipamRangesResult := gjson.GetBytes(n.CNI, "plugins.#.ipam.ranges|@flatten|@flatten") - for _, f := range ipamRangesResult.Array() { - m := f.Map() - var cfg IPAMConfig - if x, ok := m["subnet"]; ok { - cfg.Subnet = x.String() - } - if x, ok := m["gateway"]; ok { - cfg.Gateway = x.String() - } - if x, ok := m["ipRange"]; ok { - cfg.IPRange = x.String() + res.Name = sCNI.Name + for _, plugin := range sCNI.Plugins { + for _, ranges := range plugin.Ipam.Ranges { + res.IPAM.Config = append(res.IPAM.Config, ranges...) } - res.IPAM.Config = append(res.IPAM.Config, cfg) } if n.NerdctlID != nil { @@ -485,18 +555,12 @@ func parseMounts(nerdctlMounts string) ([]MountPoint, error) { return nil, err } - for i := range mounts { - rw, propagation := parseMountProperties(mounts[i].Mode) - mounts[i].RW = rw - mounts[i].Propagation = propagation - } - return mounts, nil } -func parseMountProperties(option string) (rw bool, propagation string) { +func ParseMountProperties(option []string) (rw bool, propagation string) { rw = true - for _, opt := range strings.Split(option, ",") { + for _, opt := range option { switch opt { case "ro", "rro": rw = false diff --git a/pkg/inspecttypes/dockercompat/dockercompat_test.go b/pkg/inspecttypes/dockercompat/dockercompat_test.go new file mode 100644 index 00000000000..12814bc31fc --- /dev/null +++ b/pkg/inspecttypes/dockercompat/dockercompat_test.go @@ -0,0 +1,363 @@ +/* + 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 ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/docker/go-connections/nat" + "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/nerdctl/v2/pkg/inspecttypes/native" +) + +func TestContainerFromNative(t *testing.T) { + tempStateDir, err := os.MkdirTemp(t.TempDir(), "rw") + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) + defer os.RemoveAll(tempStateDir) + + testcase := []struct { + name string + n *native.Container + expected *Container + }{ + // nerdctl container, mount /mnt/foo:/mnt/foo:rw,rslave; ResolvConfPath; hostname + { + name: "container from nerdctl", + n: &native.Container{ + Container: containers.Container{ + Labels: map[string]string{ + "nerdctl/mounts": "[{\"Type\":\"bind\",\"Source\":\"/mnt/foo\",\"Destination\":\"/mnt/foo\",\"Mode\":\"rshared,rw\",\"RW\":true,\"Propagation\":\"rshared\"}]", + "nerdctl/state-dir": tempStateDir, + "nerdctl/hostname": "host1", + }, + }, + Spec: &specs.Spec{}, + Process: &native.Process{ + Pid: 10000, + Status: containerd.Status{ + Status: "running", + }, + }, + }, + expected: &Container{ + Created: "0001-01-01T00:00:00Z", + Platform: runtime.GOOS, + ResolvConfPath: filepath.Join(tempStateDir, "resolv.conf"), + State: &ContainerState{ + Status: "running", + Running: true, + Pid: 10000, + FinishedAt: "", + }, + Mounts: []MountPoint{ + { + Type: "bind", + Source: "/mnt/foo", + Destination: "/mnt/foo", + Mode: "rshared,rw", + RW: true, + Propagation: "rshared", + }, + }, + Config: &Config{ + Labels: map[string]string{ + "nerdctl/mounts": "[{\"Type\":\"bind\",\"Source\":\"/mnt/foo\",\"Destination\":\"/mnt/foo\",\"Mode\":\"rshared,rw\",\"RW\":true,\"Propagation\":\"rshared\"}]", + "nerdctl/state-dir": tempStateDir, + "nerdctl/hostname": "host1", + }, + Hostname: "host1", + }, + NetworkSettings: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{}, + }, + }, + }, + // cri container, mount /mnt/foo:/mnt/foo:rw,rslave; mount resolv.conf and hostname; internal sysfs mount + { + name: "container from cri", + n: &native.Container{ + Container: containers.Container{}, + Spec: &specs.Spec{ + Mounts: []specs.Mount{ + { + Destination: "/etc/resolv.conf", + Type: "bind", + Source: "/mock-sandbox-dir/resolv.conf", + Options: []string{"rbind", "rprivate", "rw"}, + }, + { + Destination: "/etc/hostname", + Type: "bind", + Source: "/mock-sandbox-dir/hostname", + Options: []string{"rbind", "rprivate", "rw"}, + }, + { + Destination: "/mnt/foo", + Type: "bind", + Source: "/mnt/foo", + Options: []string{"rbind", "rslave", "rw"}, + }, + { + Destination: "/sys", + Type: "sysfs", + Source: "sysfs", + Options: []string{"nosuid", "noexec", "nodev", "ro"}, + }, + }, + }, + Process: &native.Process{ + Pid: 10000, + Status: containerd.Status{ + Status: "running", + }, + }, + }, + expected: &Container{ + Created: "0001-01-01T00:00:00Z", + Platform: runtime.GOOS, + ResolvConfPath: "/mock-sandbox-dir/resolv.conf", + HostnamePath: "/mock-sandbox-dir/hostname", + State: &ContainerState{ + Status: "running", + Running: true, + Pid: 10000, + FinishedAt: "", + }, + Mounts: []MountPoint{ + { + Type: "bind", + Source: "/mock-sandbox-dir/resolv.conf", + Destination: "/etc/resolv.conf", + Mode: "rbind,rprivate,rw", + RW: true, + Propagation: "rprivate", + }, + { + Type: "bind", + Source: "/mock-sandbox-dir/hostname", + Destination: "/etc/hostname", + Mode: "rbind,rprivate,rw", + RW: true, + Propagation: "rprivate", + }, + { + Type: "bind", + Source: "/mnt/foo", + Destination: "/mnt/foo", + Mode: "rbind,rslave,rw", + RW: true, + Propagation: "rslave", + }, + // ignore sysfs mountpoint + }, + Config: &Config{}, + NetworkSettings: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{}, + }, + }, + }, + // ctr container, mount /mnt/foo:/mnt/foo:rw,rslave; internal sysfs mount; hostname + { + name: "container from ctr", + n: &native.Container{ + Container: containers.Container{}, + Spec: &specs.Spec{ + Hostname: "host1", + Mounts: []specs.Mount{ + { + Destination: "/mnt/foo", + Type: "bind", + Source: "/mnt/foo", + Options: []string{"rbind", "rslave", "rw"}, + }, + { + Destination: "/sys", + Type: "sysfs", + Source: "sysfs", + Options: []string{"nosuid", "noexec", "nodev", "ro"}, + }, + }, + }, + Process: &native.Process{ + Pid: 10000, + Status: containerd.Status{ + Status: "running", + }, + }, + }, + expected: &Container{ + Created: "0001-01-01T00:00:00Z", + Platform: runtime.GOOS, + State: &ContainerState{ + Status: "running", + Running: true, + Pid: 10000, + FinishedAt: "", + }, + Mounts: []MountPoint{ + { + Type: "bind", + Source: "/mnt/foo", + Destination: "/mnt/foo", + Mode: "rbind,rslave,rw", + RW: true, + Propagation: "rslave", + }, + // ignore sysfs mountpoint + }, + Config: &Config{ + Hostname: "host1", + }, + NetworkSettings: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{}, + }, + }, + }, + } + + for _, tc := range testcase { + t.Run(tc.name, func(tt *testing.T) { + d, _ := ContainerFromNative(tc.n) + assert.DeepEqual(tt, d, tc.expected) + }) + } +} + +func TestNetworkSettingsFromNative(t *testing.T) { + tempStateDir, err := os.MkdirTemp(t.TempDir(), "rw") + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(tempStateDir, "resolv.conf"), []byte(""), 0644) + defer os.RemoveAll(tempStateDir) + + testcase := []struct { + name string + n *native.NetNS + s *specs.Spec + expected *NetworkSettings + }{ + // Given null native.NetNS, Return initialized NetworkSettings + // UseCase: Inspect a Stopped Container + { + name: "Given Null NetNS, Return initialized NetworkSettings", + n: nil, + s: &specs.Spec{}, + expected: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{}, + }, + }, + // Given native.NetNS with single Interface with Port Annotations, Return populated NetworkSettings + // UseCase: Inspect a Running Container with published ports + { + name: "Given NetNS with single Interface with Port Annotation, Return populated NetworkSettings", + n: &native.NetNS{ + Interfaces: []native.NetInterface{ + { + Interface: net.Interface{ + Index: 1, + MTU: 1500, + Name: "eth0.100", + Flags: net.FlagUp, + }, + HardwareAddr: "xx:xx:xx:xx:xx:xx", + Flags: []string{}, + Addrs: []string{"10.0.4.30/24"}, + }, + }, + }, + s: &specs.Spec{ + Annotations: map[string]string{ + "nerdctl/ports": "[{\"HostPort\":8075,\"ContainerPort\":77,\"Protocol\":\"tcp\",\"HostIP\":\"127.0.0.1\"}]", + }, + }, + expected: &NetworkSettings{ + Ports: &nat.PortMap{ + nat.Port("77/tcp"): []nat.PortBinding{ + { + HostIP: "127.0.0.1", + HostPort: "8075", + }, + }, + }, + Networks: map[string]*NetworkEndpointSettings{ + "unknown-eth0.100": { + IPAddress: "10.0.4.30", + IPPrefixLen: 24, + MacAddress: "xx:xx:xx:xx:xx:xx", + }, + }, + }, + }, + // Given native.NetNS with single Interface without Port Annotations, Return valid NetworkSettings w/ empty Ports + // UseCase: Inspect a Running Container without published ports + { + name: "Given NetNS with single Interface without Port Annotations, Return valid NetworkSettings w/ empty Ports", + n: &native.NetNS{ + Interfaces: []native.NetInterface{ + { + Interface: net.Interface{ + Index: 1, + MTU: 1500, + Name: "eth0.100", + Flags: net.FlagUp, + }, + HardwareAddr: "xx:xx:xx:xx:xx:xx", + Flags: []string{}, + Addrs: []string{"10.0.4.30/24"}, + }, + }, + }, + s: &specs.Spec{ + Annotations: map[string]string{}, + }, + expected: &NetworkSettings{ + Ports: &nat.PortMap{}, + Networks: map[string]*NetworkEndpointSettings{ + "unknown-eth0.100": { + IPAddress: "10.0.4.30", + IPPrefixLen: 24, + MacAddress: "xx:xx:xx:xx:xx:xx", + }, + }, + }, + }, + } + + for _, tc := range testcase { + t.Run(tc.name, func(tt *testing.T) { + d, _ := networkSettingsFromNative(tc.n, tc.s) + assert.DeepEqual(tt, d, tc.expected) + }) + } +} diff --git a/pkg/inspecttypes/native/container.go b/pkg/inspecttypes/native/container.go index a47b7de174d..de015dd5f94 100644 --- a/pkg/inspecttypes/native/container.go +++ b/pkg/inspecttypes/native/container.go @@ -19,8 +19,8 @@ package native import ( "net" - "github.com/containerd/containerd" - "github.com/containerd/containerd/containers" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" ) // Container corresponds to a containerd-native container object. diff --git a/pkg/inspecttypes/native/image.go b/pkg/inspecttypes/native/image.go index 5eb5499b60d..d7e1ac388d9 100644 --- a/pkg/inspecttypes/native/image.go +++ b/pkg/inspecttypes/native/image.go @@ -17,8 +17,9 @@ package native import ( - "github.com/containerd/containerd/images" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" ) // Image corresponds to a containerd-native image object. diff --git a/pkg/ipcutil/ipcutil.go b/pkg/ipcutil/ipcutil.go new file mode 100644 index 00000000000..7fd3240f15c --- /dev/null +++ b/pkg/ipcutil/ipcutil.go @@ -0,0 +1,252 @@ +/* + 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 ipcutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "path/filepath" + "runtime" + "strings" + + "github.com/docker/go-units" + "github.com/opencontainers/runtime-spec/specs-go" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/pkg/oci" + + "github.com/containerd/nerdctl/v2/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/v2/pkg/labels" +) + +type IPCMode string + +type IPC struct { + Mode IPCMode `json:"mode,omitempty"` + // VictimContainer is only used when mode is container + VictimContainerID *string `json:"victimContainerId,omitempty"` + + // HostShmPath is only used when mode is shareable + HostShmPath *string `json:"hostShmPath,omitempty"` + + // ShmSize is only used when mode is private or shareable + // Devshm size in bytes + ShmSize string `json:"shmSize,omitempty"` +} + +const ( + Private IPCMode = "private" + Host IPCMode = "host" + Shareable IPCMode = "shareable" + Container IPCMode = "container" +) + +// DetectFlags detects IPC mode from the given ipc string and shmSize string. +// If ipc is empty, it returns IPC{Mode: Private}. +func DetectFlags(ctx context.Context, client *containerd.Client, stateDir string, ipc string, shmSize string) (IPC, error) { + var res IPC + res.ShmSize = shmSize + switch ipc { + case "", "private": + res.Mode = Private + case "host": + res.Mode = Host + case "shareable": + res.Mode = Shareable + shmPath := filepath.Join(stateDir, "shm") + res.HostShmPath = &shmPath + default: // container: + res.Mode = Container + parsed := strings.Split(ipc, ":") + if len(parsed) < 2 || parsed[0] != "container" { + return res, fmt.Errorf("invalid ipc namespace. Set --ipc=[host|container:") + } + + containerName := parsed[1] + 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) + } + victimContainerID := found.Container.ID() + res.VictimContainerID = &victimContainerID + + return nil + }, + } + matchedCount, err := walker.Walk(ctx, containerName) + if err != nil { + return res, err + } + if matchedCount < 1 { + return res, fmt.Errorf("no such container: %s", containerName) + } + } + + return res, nil +} + +// EncodeIPCLabel encodes IPC spec into a label. +func EncodeIPCLabel(ipc IPC) (string, error) { + if ipc.Mode == "" { + return "", nil + } + b, err := json.Marshal(ipc) + if err != nil { + return "", err + } + return string(b), nil +} + +// DecodeIPCLabel decodes IPC spec from a label. +// For backward compatibility, if ipcLabel is empty, it returns IPC{Mode: Private}. +func DecodeIPCLabel(ipcLabel string) (IPC, error) { + if ipcLabel == "" { + return IPC{ + Mode: Private, + }, nil + } + + var ipc IPC + if err := json.Unmarshal([]byte(ipcLabel), &ipc); err != nil { + return IPC{}, err + } + return ipc, nil +} + +// GenerateIPCOpts generates IPC spec opts from the given IPC. +func GenerateIPCOpts(ctx context.Context, ipc IPC, client *containerd.Client) ([]oci.SpecOpts, error) { + opts := make([]oci.SpecOpts, 0) + + switch ipc.Mode { + case Private: + // If nothing is specified, or if private, default to normal behavior + if len(ipc.ShmSize) > 0 { + shmBytes, err := units.RAMInBytes(ipc.ShmSize) + if err != nil { + return nil, err + } + opts = append(opts, oci.WithDevShmSize(shmBytes/1024)) + } + case Host: + opts = append(opts, withBindMountHostIPC) + if runtime.GOOS != "windows" { + opts = append(opts, oci.WithHostNamespace(specs.IPCNamespace)) + } + case Shareable: + if ipc.HostShmPath == nil { + return nil, errors.New("ipc mode is shareable, but host shm path is nil") + } + err := makeShareableDevshm(*ipc.HostShmPath, ipc.ShmSize) + if err != nil { + return nil, err + } + opts = append(opts, withBindMountHostOtherSourceIPC(*ipc.HostShmPath)) + case Container: + if ipc.VictimContainerID == nil { + return nil, errors.New("ipc mode is container, but victim container id is nil") + } + targetCon, err := client.LoadContainer(ctx, *ipc.VictimContainerID) + if err != nil { + return nil, err + } + + task, err := targetCon.Task(ctx, nil) + if err != nil { + return nil, err + } + + status, err := task.Status(ctx) + if err != nil { + return nil, err + } + + if status.Status != containerd.Running { + return nil, fmt.Errorf("shared container is not running") + } + + targetConLabels, err := targetCon.Labels(ctx) + if err != nil { + return nil, err + } + + targetConIPC, err := DecodeIPCLabel(targetConLabels[labels.IPC]) + if err != nil { + return nil, err + } + + if targetConIPC.Mode == Host { + opts = append(opts, oci.WithHostNamespace(specs.IPCNamespace)) + opts = append(opts, withBindMountHostIPC) + return opts, nil + } else if targetConIPC.Mode != Shareable { + return nil, errors.New("victim container's ipc mode is not shareable") + } + + if targetConIPC.HostShmPath == nil { + return nil, errors.New("victim container's host shm path is nil") + } + + opts = append(opts, withBindMountHostOtherSourceIPC(*targetConIPC.HostShmPath)) + } + + return opts, nil +} + +// WithBindMountHostOtherSourceIPC replaces /dev/shm mount with rbind by the given path on host +func withBindMountHostOtherSourceIPC(source string) oci.SpecOpts { + return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { + for i, m := range s.Mounts { + p := path.Clean(m.Destination) + if p == "/dev/shm" { + s.Mounts[i] = specs.Mount{ + Type: "bind", + Destination: p, + Source: source, + Options: []string{"rbind", "nosuid", "noexec", "nodev"}, + } + } + } + return nil + } +} + +// WithBindMountHostIPC replaces /dev/shm and /dev/mqueue mount with rbind. +// Required for --ipc=host on rootless. +func withBindMountHostIPC(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { + for i, m := range s.Mounts { + switch p := path.Clean(m.Destination); p { + case "/dev/shm", "/dev/mqueue": + s.Mounts[i] = specs.Mount{ + Destination: p, + Type: "bind", + Source: p, + Options: []string{"rbind", "nosuid", "noexec", "nodev"}, + } + } + } + return nil +} + +func CleanUp(ipc IPC) error { + return cleanUpPlatformSpecificIPC(ipc) +} diff --git a/pkg/ipcutil/ipcutil_linux.go b/pkg/ipcutil/ipcutil_linux.go new file mode 100644 index 00000000000..0d1b9f6cbc6 --- /dev/null +++ b/pkg/ipcutil/ipcutil_linux.go @@ -0,0 +1,62 @@ +/* + 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 ipcutil + +import ( + "fmt" + "os" + + "github.com/docker/go-units" + "golang.org/x/sys/unix" +) + +// makeShareableDevshm returns devshm directory path on host when there is no error. +func makeShareableDevshm(shmPath, shmSize string) error { + shmproperty := "mode=1777" + if len(shmSize) > 0 { + shmBytes, err := units.RAMInBytes(shmSize) + if err != nil { + return err + } + shmproperty = fmt.Sprintf("%s,size=%d", shmproperty, shmBytes) + } + err := os.MkdirAll(shmPath, 0700) + if err != nil { + return err + } + err = unix.Mount("/dev/shm", shmPath, "tmpfs", uintptr(unix.MS_NOEXEC|unix.MS_NOSUID|unix.MS_NODEV), shmproperty) + if err != nil { + return err + } + + return nil +} + +// cleanUpPlatformSpecificIPC cleans up platform specific IPC. +func cleanUpPlatformSpecificIPC(ipc IPC) error { + if ipc.Mode == Shareable && ipc.HostShmPath != nil { + err := unix.Unmount(*ipc.HostShmPath, 0) + if err != nil { + return err + } + err = os.RemoveAll(*ipc.HostShmPath) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/nerdctl/network_create_windows.go b/pkg/ipcutil/ipcutil_other.go similarity index 55% rename from cmd/nerdctl/network_create_windows.go rename to pkg/ipcutil/ipcutil_other.go index 2d6acf7e92b..a4c25963cc0 100644 --- a/cmd/nerdctl/network_create_windows.go +++ b/pkg/ipcutil/ipcutil_other.go @@ -1,3 +1,5 @@ +//go:build !(linux || windows) + /* Copyright The containerd Authors. @@ -14,19 +16,19 @@ limitations under the License. */ -package main - -import "github.com/spf13/cobra" +package ipcutil -const ( - DefaultNetworkDriver = "nat" -) +import "fmt" -func shellCompleteNetworkDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - candidates := []string{"nat"} - return candidates, cobra.ShellCompDirectiveNoFileComp +// makeShareableDevshm returns devshm directory path on host when there is no error. +func makeShareableDevshm(shmPath, shmSize string) error { + return fmt.Errorf("unix does not support shareable devshm") } -func shellCompleteIPAMDrivers(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"default"}, cobra.ShellCompDirectiveNoFileComp +// cleanUpPlatformSpecificIPC cleans up platform specific IPC. +func cleanUpPlatformSpecificIPC(ipc IPC) error { + if ipc.Mode == Shareable { + return fmt.Errorf("unix does not support shareable devshm") + } + return nil } diff --git a/pkg/ipcutil/ipcutil_windows.go b/pkg/ipcutil/ipcutil_windows.go new file mode 100644 index 00000000000..5e9d4aa391d --- /dev/null +++ b/pkg/ipcutil/ipcutil_windows.go @@ -0,0 +1,32 @@ +/* + 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 ipcutil + +import "fmt" + +// makeShareableDevshm returns devshm directory path on host when there is no error. +func makeShareableDevshm(shmPath, shmSize string) error { + return fmt.Errorf("windows does not support shareable devshm") +} + +// cleanUpPlatformSpecificIPC cleans up platform specific IPC. +func cleanUpPlatformSpecificIPC(ipc IPC) error { + if ipc.Mode == Shareable { + return fmt.Errorf("windows does not support shareable devshm") + } + return nil +} diff --git a/pkg/ipfs/image.go b/pkg/ipfs/image.go index 931c673cac0..84d73bda32e 100644 --- a/pkg/ipfs/image.go +++ b/pkg/ipfs/image.go @@ -22,30 +22,32 @@ import ( "io" "os" - "github.com/containerd/containerd" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/images/converter" - "github.com/containerd/containerd/remotes" - "github.com/containerd/nerdctl/pkg/idutil/imagewalker" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/referenceutil" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/images/converter" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/errdefs" + "github.com/containerd/log" "github.com/containerd/stargz-snapshotter/ipfs" ipfsclient "github.com/containerd/stargz-snapshotter/ipfs/client" - "github.com/docker/docker/errdefs" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/platformutil" ) const ipfsPathEnv = "IPFS_PATH" // EnsureImage pull the specified image from IPFS. -func EnsureImage(ctx context.Context, client *containerd.Client, stdout, stderr io.Writer, snapshotter string, scheme string, ref string, mode imgutil.PullMode, ocispecPlatforms []ocispec.Platform, unpack *bool, quiet bool, ipfsPath string) (*imgutil.EnsuredImage, error) { - switch mode { +func EnsureImage(ctx context.Context, client *containerd.Client, scheme, ref, ipfsPath string, options types.ImagePullOptions) (*imgutil.EnsuredImage, error) { + switch options.Mode { case "always", "missing", "never": // NOP default: - return nil, fmt.Errorf("unexpected pull mode: %q", mode) + return nil, fmt.Errorf("unexpected pull mode: %q", options.Mode) } switch scheme { case "ipfs", "ipns": @@ -55,15 +57,15 @@ func EnsureImage(ctx context.Context, client *containerd.Client, stdout, stderr } // if not `always` pull and given one platform and image found locally, return existing image directly. - if mode != "always" && len(ocispecPlatforms) == 1 { - if res, err := imgutil.GetExistingImage(ctx, client, snapshotter, ref, ocispecPlatforms[0]); err == nil { + if options.Mode != "always" && len(options.OCISpecPlatform) == 1 { + if res, err := imgutil.GetExistingImage(ctx, client, options.GOptions.Snapshotter, ref, options.OCISpecPlatform[0]); err == nil { return res, nil } else if !errdefs.IsNotFound(err) { return nil, err } } - if mode == "never" { + if options.Mode == "never" { return nil, fmt.Errorf("image %q is not available", ref) } r, err := ipfs.NewResolver(ipfs.ResolverOptions{ @@ -73,7 +75,7 @@ func EnsureImage(ctx context.Context, client *containerd.Client, stdout, stderr if err != nil { return nil, err } - return imgutil.PullImage(ctx, client, stdout, stderr, snapshotter, r, ref, ocispecPlatforms, unpack, quiet) + return imgutil.PullImage(ctx, client, r, ref, options) } // Push pushes the specified image to IPFS. @@ -85,16 +87,12 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, layerCo ipath := lookupIPFSPath(ipfsPath) if ensureImage { // Ensure image contents are fully downloaded - logrus.Infof("ensuring image contents") + log.G(ctx).Infof("ensuring image contents") if err := ensureContentsOfIPFSImage(ctx, client, rawRef, allPlatforms, platform, ipath); err != nil { - logrus.WithError(err).Warnf("failed to ensure the existence of image %q", rawRef) + log.G(ctx).WithError(err).Warnf("failed to ensure the existence of image %q", rawRef) } } - ref, err := referenceutil.ParseAny(rawRef) - if err != nil { - return "", err - } - return ipfs.PushWithIPFSPath(ctx, client, ref.String(), layerConvert, platMC, &ipath) + return ipfs.PushWithIPFSPath(ctx, client, rawRef, layerConvert, platMC, &ipath) } // ensureContentsOfIPFSImage ensures that the entire contents of an existing IPFS image are fully downloaded to containerd. diff --git a/pkg/ipfs/registry.go b/pkg/ipfs/registry.go index 6dfc9687879..038b44f70ce 100644 --- a/pkg/ipfs/registry.go +++ b/pkg/ipfs/registry.go @@ -25,16 +25,17 @@ import ( "io" "net/http" "regexp" + "strconv" "strings" "time" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - ipfsclient "github.com/containerd/stargz-snapshotter/ipfs/client" - "github.com/hashicorp/go-multierror" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/sirupsen/logrus" + + "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. @@ -72,22 +73,22 @@ 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 { - logrus.WithError(err).Warnf("failed to serve %q %q", r.Method, r.URL.Path) + 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 { - logrus.Debugf("returning without contents") + log.L.Debugf("returning without contents") w.WriteHeader(200) return } w.Header().Set("Content-Type", mediaType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) if r.Method == "GET" { http.ServeContent(w, r, "", time.Now(), content) - logrus.WithField("CID", cid).Debugf("served file") + log.L.WithField("CID", cid).Debugf("served file") } } @@ -97,7 +98,7 @@ func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, e } if r.URL.Path == "/v2/" { - logrus.Debugf("requested /v2/") + log.L.Debugf("requested /v2/") return "", nil, "", 0, nil } @@ -108,7 +109,7 @@ func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, e if !images.IsManifestType(mediaType) && !images.IsIndexType(mediaType) { return "", nil, "", 0, fmt.Errorf("cannot serve non-manifest from manifest API: %q", mediaType) } - logrus.WithField("root CID", cidStr).WithField("digest", ref).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by digest") + 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" { @@ -118,7 +119,7 @@ func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, e if err != nil { return "", nil, "", 0, err } - logrus.WithField("root CID", cidStr).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by cid") + log.L.WithField("root CID", cidStr).WithField("resolved CID", resolvedCID).Debugf("resolved manifest by cid") return resolvedCID, content, mediaType, size, nil } @@ -128,7 +129,7 @@ func (s *server) serve(r *http.Request) (string, io.ReadSeeker, string, int64, e if err != nil { return "", nil, "", 0, err } - logrus.WithField("root CID", rootCIDStr).WithField("digest", dgstStr).WithField("resolved CID", resolvedCID).Debugf("resolved blob by digest") + log.L.WithField("root CID", rootCIDStr).WithField("digest", dgstStr).WithField("resolved CID", resolvedCID).Debugf("resolved blob by digest") return resolvedCID, content, mediaType, size, nil } @@ -233,15 +234,16 @@ func (s *server) resolveCIDOfDigest(ctx context.Context, dgst digest.Digest, des if err != nil { return "", ocispec.Descriptor{}, err } - var allErr error + var errs []error for _, desc := range descs { gotCID, gotDesc, err := s.resolveCIDOfDigest(ctx, dgst, desc) if err != nil { - allErr = multierror.Append(allErr, err) + errs = append(errs, err) continue } return gotCID, gotDesc, nil } + allErr := errors.Join(errs...) if allErr == nil { return "", ocispec.Descriptor{}, fmt.Errorf("not found") } diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index 58c747a559e..e2dd6ede16d 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -15,7 +15,7 @@ */ // Package labels defines labels that are set to containerd containers as labels. -// The labels are also passed to OCI containers as annotations. +// The labels defined in this package are also passed to OCI containers as annotations. package labels const ( @@ -54,12 +54,15 @@ const ( // Currently, the length of the slice must be 1. Networks = Prefix + "networks" - // Ports is a JSON-marshalled string of []gocni.PortMapping . + // Ports is a JSON-marshalled string of []cni.PortMapping . Ports = Prefix + "ports" // IPAddress is the static IP address of the container assigned by the user IPAddress = Prefix + "ip" + // IP6Address is the static IP6 address of the container assigned by the user + IP6Address = Prefix + "ip6" + // LogURI is the log URI LogURI = Prefix + "log-uri" @@ -76,19 +79,18 @@ const ( // Mounts is the mount points for the container. Mounts = Prefix + "mounts" - // Bypass4netns is the flag for acceleration with bypass4netns - // Boolean value which can be parsed with strconv.ParseBool() is required. - // (like "nerdctl/bypass4netns=true" or "nerdctl/bypass4netns=false") - Bypass4netns = Prefix + "bypass4netns" - // StopTimeout is seconds to wait for stop a container. - StopTimout = Prefix + "stop-timeout" + StopTimeout = Prefix + "stop-timeout" MACAddress = Prefix + "mac-address" // PIDContainer is the `nerdctl run --pid` for restarting PIDContainer = Prefix + "pid-container" + // IPC is the `nerectl run --ipc` for restrating + // IPC indicates ipc victim container. + IPC = Prefix + "ipc" + // Error encapsulates a container human-readable string // that describes container error. Error = Prefix + "error" @@ -98,10 +100,7 @@ const ( // Boolean value which can be parsed with strconv.ParseBool() is required. // (like "nerdctl/default-network=true" or "nerdctl/default-network=false") NerdctlDefaultNetwork = Prefix + "default-network" -) -var ShellCompletions = []string{ - Bypass4netns + "=true", - Bypass4netns + "=false", - // Other labels should not be set via CLI -} + // ContainerAutoRemove is to check whether the --rm option is specified. + ContainerAutoRemove = Prefix + "auto-remove" +) diff --git a/pkg/lockutil/lockutil_unix.go b/pkg/lockutil/lockutil_unix.go index 4f8a2a06a8e..de99ca9a4e1 100644 --- a/pkg/lockutil/lockutil_unix.go +++ b/pkg/lockutil/lockutil_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -22,8 +22,9 @@ import ( "fmt" "os" - "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + + "github.com/containerd/log" ) func WithDirLock(dir string, fn func() error) error { @@ -32,18 +33,18 @@ func WithDirLock(dir string, fn func() error) error { return err } defer dirFile.Close() - if err := Flock(dirFile, unix.LOCK_EX); err != nil { + 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 { - logrus.WithError(err).Errorf("failed to unlock %q", dir) + 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 { +func flock(f *os.File, flags int) error { fd := int(f.Fd()) for { err := unix.Flock(fd, flags) @@ -52,3 +53,24 @@ func Flock(f *os.File, flags int) error { } } } + +func Lock(dir string) (*os.File, error) { + 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 index bcfd9776556..205efde83f5 100644 --- a/pkg/lockutil/lockutil_windows.go +++ b/pkg/lockutil/lockutil_windows.go @@ -20,8 +20,9 @@ import ( "fmt" "os" - "github.com/sirupsen/logrus" "golang.org/x/sys/windows" + + "github.com/containerd/log" ) func WithDirLock(dir string, fn func() error) error { @@ -31,15 +32,34 @@ func WithDirLock(dir string, fn func() error) error { } defer dirFile.Close() // see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203(v=vs.85).aspx - // 1 lock immediately - if err = windows.LockFileEx(windows.Handle(dirFile.Fd()), 1, 0, 1, 0, &windows.Overlapped{}); err != nil { + 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, 1, 0, &windows.Overlapped{}); err != nil { - logrus.WithError(err).Errorf("failed to unlock %q", dir) + 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.go b/pkg/logging/cri_logger.go index c47606b2aa0..cf582d3c887 100644 --- a/pkg/logging/cri_logger.go +++ b/pkg/logging/cri_logger.go @@ -25,6 +25,7 @@ package logging import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -33,9 +34,11 @@ import ( "path/filepath" "time" - "github.com/containerd/containerd/log" - "github.com/containerd/nerdctl/pkg/logging/tail" - "github.com/sirupsen/logrus" + "github.com/fsnotify/fsnotify" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/logging/tail" ) // LogStreamType is the type of the stream in CRI container log. @@ -46,6 +49,9 @@ const ( Stdout LogStreamType = "stdout" // Stderr is the stream type for stderr. Stderr LogStreamType = "stderr" + + // logForceCheckPeriod is the period to check for a new read + logForceCheckPeriod = 1 * time.Second ) // LogTag is the tag of a log line in CRI container log. @@ -90,7 +96,9 @@ func ReadLogs(opts *LogViewOptions, stdout, stderr io.Writer, stopChannel chan o if err != nil { return fmt.Errorf("failed to open log file %q: %v", logPath, err) } - defer f.Close() + defer func() { + f.Close() + }() // Search start point based on tail line. start, err := tail.FindTailLineStartIndex(f, opts.Tail) @@ -102,6 +110,8 @@ func ReadLogs(opts *LogViewOptions, stdout, stderr io.Writer, stopChannel chan o return fmt.Errorf("failed to seek in log file %q: %v", logPath, err) } + var watcher *fsnotify.Watcher + limitedMode := (opts.Tail > 0) && (!opts.Follow) limitedNum := opts.Tail // Start parsing the logs. @@ -111,14 +121,17 @@ func ReadLogs(opts *LogViewOptions, stdout, stderr io.Writer, stopChannel chan o isNewLine := true writer := newLogWriter(stdout, stderr, opts) msg := &logMessage{} + baseName := filepath.Base(logPath) + dir := filepath.Dir(logPath) + for { select { case <-stopChannel: - logrus.Debugf("received stop signal while reading cri logfile, returning") + log.L.Debugf("received stop signal while reading cri logfile, returning") return nil default: if stop || (limitedMode && limitedNum == 0) { - logrus.Debugf("finished parsing log file, path: %s", logPath) + log.L.Debugf("finished parsing log file, path: %s", logPath) return nil } l, err := r.ReadBytes(eol[0]) @@ -127,13 +140,48 @@ func ReadLogs(opts *LogViewOptions, stdout, stderr io.Writer, stopChannel chan o return fmt.Errorf("failed to read log file %q: %v", logPath, err) } if opts.Follow { - // Reset seek so that if this is an incomplete line, // it will be read again. if _, err := f.Seek(-int64(len(l)), io.SeekCurrent); err != nil { return fmt.Errorf("failed to reset seek in log file %q: %v", logPath, err) } + if watcher == nil { + // Initialize the watcher if it has not been initialized yet. + if watcher, err = NewLogFileWatcher(dir); err != nil { + return err + } + defer watcher.Close() + // If we just created the watcher, try again to read as we might have missed + // the event. + continue + } + + var recreated bool + // Wait until the next log change. + recreated, err = startTail(context.Background(), baseName, watcher) + if err != nil { + return err + } + if recreated { + newF, err := openFileShareDelete(logPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + //If the user application outputs logs too quickly, + //There is a slight possibility that nerdctl has just rotated the log file, + //try opening it once more. + time.Sleep(10 * time.Millisecond) + } + newF, err = openFileShareDelete(logPath) + if err != nil { + return fmt.Errorf("failed to open cri logfile %q: %w", logPath, err) + } + } + f.Close() + f = newF + r = bufio.NewReader(f) + } + // If the container exited consume data until the next EOF continue } @@ -142,22 +190,22 @@ func ReadLogs(opts *LogViewOptions, stdout, stderr io.Writer, stopChannel chan o if len(l) == 0 { continue } - logrus.Debugf("incomplete line in log file, path: %s, line: %s", logPath, l) + log.L.Debugf("incomplete line in log file, path: %s, line: %s", logPath, l) } // Parse the log line. msg.reset() if err := ParseCRILog(l, msg); err != nil { - logrus.WithError(err).Errorf("failed when parsing line in log file, path: %s, line: %s", logPath, l) + log.L.WithError(err).Errorf("failed when parsing line in log file, path: %s, line: %s", logPath, l) continue } // Write the log line into the stream. if err := writer.write(msg, isNewLine); err != nil { if err == errMaximumWrite { - logrus.Debugf("finished parsing log file, hit bytes limit path: %s", logPath) + log.L.Debugf("finished parsing log file, hit bytes limit path: %s", logPath) return nil } - logrus.WithError(err).Errorf("failed when writing line to log file, path: %s, line: %s", logPath, l) + log.L.WithError(err).Errorf("failed when writing line to log file, path: %s, line: %s", logPath, l) return err } if limitedMode { diff --git a/pkg/logging/cri_logger_test.go b/pkg/logging/cri_logger_test.go index fff79506ae6..6d45e4999bc 100644 --- a/pkg/logging/cri_logger_test.go +++ b/pkg/logging/cri_logger_test.go @@ -28,7 +28,9 @@ import ( "fmt" "io" "os" + "path/filepath" "reflect" + "runtime" "testing" "time" ) @@ -89,7 +91,7 @@ func TestReadLogs(t *testing.T) { err = ReadLogs(&tc.logViewOptions, stdoutBuf, stderrBuf, stopChan) if err != nil { - t.Fatalf(err.Error()) + t.Fatal(err.Error()) } if stderrBuf.Len() > 0 { t.Fatalf("Stderr: %v", stderrBuf.String()) @@ -157,9 +159,8 @@ func TestParseLog(t *testing.T) { if err != nil { if test.err { continue - } else { - t.Errorf("ParseCRILog err %s ", err.Error()) } + t.Errorf("ParseCRILog err %s ", err.Error()) } if !reflect.DeepEqual(test.msg, logmsg) { @@ -231,3 +232,88 @@ func TestReadLogsLimitsWithTimestamps(t *testing.T) { t.Errorf("should have two lines, lineCount= %d", lineCount) } } + +func TestReadRotatedLog(t *testing.T) { + tmpDir := t.TempDir() + if runtime.GOOS == "windows" { + t.Skip("windows implementation does not seem to work right now and should be fixed: https://github.com/containerd/nerdctl/issues/3554") + } + file, err := os.CreateTemp(tmpDir, "logfile") + if err != nil { + t.Errorf("unable to create temp file, error: %s", err.Error()) + } + stdoutBuf := &bytes.Buffer{} + stderrBuf := &bytes.Buffer{} + containerStoped := make(chan os.Signal) + // Start to follow the container's log. + fileName := file.Name() + go func() { + lvOpts := &LogViewOptions{ + Follow: true, + LogPath: fileName, + } + _ = ReadLogs(lvOpts, stdoutBuf, stderrBuf, containerStoped) + }() + + // log in stdout + expectedStdout := "line0line2line4line6line8" + // log in stderr + expectedStderr := "line1line3line5line7line9" + + dir := filepath.Dir(file.Name()) + baseName := filepath.Base(file.Name()) + + // Write 10 lines to log file. + // Let ReadLogs start. + time.Sleep(50 * time.Millisecond) + + for line := 0; line < 10; line++ { + // 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)) + } else { + file.WriteString(fmt.Sprintf( + "%s stderr P line%d\n", now, line)) + } + + time.Sleep(1 * time.Millisecond) + + if line == 5 { + file.Close() + // Pretend to rotate the log. + rotatedName := fmt.Sprintf("%s.%s", baseName, time.Now().Format("220060102-150405")) + rotatedName = filepath.Join(dir, rotatedName) + if err := os.Rename(filepath.Join(dir, baseName), rotatedName); err != nil { + t.Errorf("failed to rotate log %q to %q, error: %s", file.Name(), rotatedName, err.Error()) + return + } + + time.Sleep(20 * time.Millisecond) + newF := filepath.Join(dir, baseName) + if file, err = os.Create(newF); err != nil { + t.Errorf("unable to create new log file, error: %s", err.Error()) + return + } + } + } + + // Finished writing into the file, close it, so we can delete it later. + err = file.Close() + if err != nil { + t.Errorf("could not close file, error: %s", err.Error()) + } + + time.Sleep(2 * time.Second) + // Make the function ReadLogs end. + close(containerStoped) + + if expectedStdout != stdoutBuf.String() { + t.Errorf("expected: %s, acoutal: %s", expectedStdout, stdoutBuf.String()) + } + + if expectedStderr != stderrBuf.String() { + t.Errorf("expected: %s, acoutal: %s", expectedStderr, stderrBuf.String()) + } +} diff --git a/pkg/logging/fluentd_logger.go b/pkg/logging/fluentd_logger.go index 402a26292b8..c0a7d579fdb 100644 --- a/pkg/logging/fluentd_logger.go +++ b/pkg/logging/fluentd_logger.go @@ -17,6 +17,7 @@ package logging import ( + "context" "fmt" "math" "net/url" @@ -26,10 +27,12 @@ import ( "sync" "time" - "github.com/containerd/containerd/runtime/v2/logging" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/fluent/fluent-logger-golang/fluent" - "github.com/sirupsen/logrus" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/strutil" ) type FluentdLogger struct { @@ -77,11 +80,11 @@ const ( func FluentdLogOptsValidate(logOptMap map[string]string) error { for key := range logOptMap { if !strutil.InStringSlice(FluentdLogOpts, key) { - logrus.Warnf("log-opt %s is ignored for fluentd log driver", key) + log.L.Warnf("log-opt %s is ignored for fluentd log driver", key) } } if _, ok := logOptMap[fluentAddress]; !ok { - logrus.Warnf("%s is missing for fluentd log driver, the default value %s:%d will be used", fluentAddress, defaultHost, defaultPort) + log.L.Warnf("%s is missing for fluentd log driver, the default value %s:%d will be used", fluentAddress, defaultHost, defaultPort) } return nil } @@ -97,7 +100,7 @@ func (f *FluentdLogger) Init(dataStore, ns, id string) error { return nil } -func (f *FluentdLogger) PreProcess(_ string, config *logging.Config) error { +func (f *FluentdLogger) PreProcess(_ context.Context, _ string, config *logging.Config) error { if runtime.GOOS == "windows" { // TODO: support fluentd on windows return fmt.Errorf("logging to fluentd is not supported on windows") diff --git a/pkg/logging/journald_logger.go b/pkg/logging/journald_logger.go index f14aa651b70..404d4abd02f 100644 --- a/pkg/logging/journald_logger.go +++ b/pkg/logging/journald_logger.go @@ -18,6 +18,7 @@ package logging import ( "bytes" + "context" "errors" "fmt" "io" @@ -28,12 +29,16 @@ import ( "text/template" "time" - "github.com/containerd/containerd/runtime/v2/logging" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/coreos/go-systemd/v22/journal" "github.com/docker/cli/templates" timetypes "github.com/docker/docker/api/types/time" - "github.com/sirupsen/logrus" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) var JournalDriverLogOpts = []string{ @@ -43,15 +48,16 @@ var JournalDriverLogOpts = []string{ func JournalLogOptsValidate(logOptMap map[string]string) error { for key := range logOptMap { if !strutil.InStringSlice(JournalDriverLogOpts, key) { - logrus.Warnf("log-opt %s is ignored for journald log driver", key) + log.L.Warnf("log-opt %s is ignored for journald log driver", key) } } return nil } type JournaldLogger struct { - Opts map[string]string - vars map[string]string + Opts map[string]string + vars map[string]string + Address string } type identifier struct { @@ -64,7 +70,7 @@ func (journaldLogger *JournaldLogger) Init(dataStore, ns, id string) error { return nil } -func (journaldLogger *JournaldLogger) PreProcess(dataStore string, config *logging.Config) error { +func (journaldLogger *JournaldLogger) PreProcess(ctx context.Context, dataStore string, config *logging.Config) error { if !journal.Enabled() { return errors.New("the local systemd journal is not available for logging") } @@ -93,9 +99,37 @@ func (journaldLogger *JournaldLogger) PreProcess(dataStore string, config *loggi syslogIdentifier = b.String() } } + + client, ctx, cancel, err := clientutil.NewClient(ctx, config.Namespace, journaldLogger.Address) + if err != nil { + return err + } + defer func() { + cancel() + client.Close() + }() + containerID := config.ID + container, err := client.LoadContainer(ctx, containerID) + if err != nil { + return err + } + containerLabels, err := container.Labels(ctx) + if err != nil { + return err + } + containerInfo, err := container.Info(ctx) + if err != nil { + return err + } + // construct log metadata for the container vars := map[string]string{ "SYSLOG_IDENTIFIER": syslogIdentifier, + "CONTAINER_TAG": syslogIdentifier, + "CONTAINER_ID": shortID, + "CONTAINER_ID_FULL": containerID, + "CONTAINER_NAME": containerutil.GetContainerName(containerLabels), + "IMAGE_NAME": containerInfo.Image, } journaldLogger.vars = vars return nil @@ -141,7 +175,7 @@ func FetchLogs(stdout, stderr io.Writer, journalctlArgs []string, stopChannel ch // Setup killing goroutine: go func() { <-stopChannel - logrus.Debugf("killing journalctl logs process with PID: %#v", cmd.Process.Pid) + log.L.Debugf("killing journalctl logs process with PID: %#v", cmd.Process.Pid) cmd.Process.Kill() }() @@ -172,7 +206,7 @@ func viewLogsJournald(lvopts LogViewOptions, stdout, stderr io.Writer, stopChann journalctlArgs = append(journalctlArgs, "--since", date) } if lvopts.Timestamps { - logrus.Warnf("unsupported Timestamps option for journald driver") + log.L.Warnf("unsupported Timestamps option for journald driver") } if lvopts.Until != "" { // using GetTimestamp from moby to keep time format consistency diff --git a/pkg/logging/json_logger.go b/pkg/logging/json_logger.go index 61f84e2db34..988e847d5e9 100644 --- a/pkg/logging/json_logger.go +++ b/pkg/logging/json_logger.go @@ -17,21 +17,26 @@ package logging import ( + "context" "errors" "fmt" "io" "os" - "os/exec" "path/filepath" "strconv" "time" - "github.com/containerd/containerd/runtime/v2/logging" - "github.com/containerd/nerdctl/pkg/logging/jsonfile" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/docker/go-units" "github.com/fahedouch/go-logrotate" - "github.com/sirupsen/logrus" + "github.com/fsnotify/fsnotify" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/logging/jsonfile" + "github.com/containerd/nerdctl/v2/pkg/logging/tail" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) var JSONDriverLogOpts = []string{ @@ -48,7 +53,7 @@ type JSONLogger struct { func JSONFileLogOptsValidate(logOptMap map[string]string) error { for key := range logOptMap { if !strutil.InStringSlice(JSONDriverLogOpts, key) { - logrus.Warnf("log-opt %s is ignored for json-file log driver", key) + log.L.Warnf("log-opt %s is ignored for json-file log driver", key) } } return nil @@ -73,7 +78,7 @@ func (jsonLogger *JSONLogger) Init(dataStore, ns, id string) error { return nil } -func (jsonLogger *JSONLogger) PreProcess(dataStore string, config *logging.Config) error { +func (jsonLogger *JSONLogger) PreProcess(ctx context.Context, dataStore string, config *logging.Config) error { var jsonFilePath string if logPath, ok := jsonLogger.Opts[LogPath]; ok { jsonFilePath = logPath @@ -83,10 +88,11 @@ func (jsonLogger *JSONLogger) PreProcess(dataStore string, config *logging.Confi l := &logrotate.Logger{ Filename: jsonFilePath, } - //maxSize Defaults to unlimited. - var capVal int64 - capVal = -1 + // MaxBytes is the maximum size in bytes of the log file before it gets + // rotated. If not set, it defaults to 100 MiB. + // see: https://github.com/fahedouch/go-logrotate/blob/6a8beddaea39b2b9c77109d7fa2fe92053c063e5/logrotate.go#L500 if capacity, ok := jsonLogger.Opts[MaxSize]; ok { + var capVal int64 var err error capVal, err = units.FromHumanSize(capacity) if err != nil { @@ -95,8 +101,8 @@ func (jsonLogger *JSONLogger) PreProcess(dataStore string, config *logging.Confi if capVal <= 0 { return fmt.Errorf("max-size must be a positive number") } + l.MaxBytes = capVal } - l.MaxBytes = capVal maxFile := 1 if maxFileString, ok := jsonLogger.Opts[MaxFile]; ok { var err error @@ -127,12 +133,18 @@ func (jsonLogger *JSONLogger) PostProcess() error { func viewLogsJSONFile(lvopts LogViewOptions, stdout, stderr io.Writer, stopChannel chan os.Signal) error { logFilePath := jsonfile.Path(lvopts.DatastoreRootPath, lvopts.Namespace, lvopts.ContainerID) if _, err := os.Stat(logFilePath); err != nil { - return fmt.Errorf("failed to stat JSON log file ") + // FIXME: this is a workaround for the actual issue, not a real solution + // https://github.com/containerd/nerdctl/issues/3187 + if errors.Is(err, errdefs.ErrNotFound) { + log.L.Warnf("Racing log file creation. Pausing briefly.") + time.Sleep(200 * time.Millisecond) + _, err = os.Stat(logFilePath) + } + if err != nil { + return fmt.Errorf("failed to stat JSON log file %w", err) + } } - if checkExecutableAvailableInPath("tail") { - return viewLogsJSONFileThroughTailExec(lvopts, logFilePath, stdout, stderr, stopChannel) - } return viewLogsJSONFileDirect(lvopts, logFilePath, stdout, stderr, stopChannel) } @@ -144,89 +156,100 @@ func viewLogsJSONFileDirect(lvopts LogViewOptions, jsonLogFilePath string, stdou if err != nil { return err } - defer fin.Close() - err = jsonfile.Decode(stdout, stderr, fin, lvopts.Timestamps, lvopts.Since, lvopts.Until, lvopts.Tail) - if err != nil { - return fmt.Errorf("error occurred while doing initial read of JSON logfile %q: %s", jsonLogFilePath, err) - } + defer func() { fin.Close() }() - if lvopts.Follow { - // Get the current file handler's seek. - lastPos, err := fin.Seek(0, io.SeekCurrent) - if err != nil { - return fmt.Errorf("error occurred while trying to seek JSON logfile %q at position %d: %s", jsonLogFilePath, lastPos, err) - } - fin.Close() - for { - select { - case <-stopChannel: - logrus.Debugf("received stop signal while re-reading JSON logfile, returning") + // Search start point based on tail line. + start, err := tail.FindTailLineStartIndex(fin, lvopts.Tail) + if err != nil { + return fmt.Errorf("failed to tail %d lines of JSON logfile %q: %w", lvopts.Tail, jsonLogFilePath, err) + } + + if _, err := fin.Seek(start, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek in log file %q from %d position: %w", jsonLogFilePath, start, err) + } + + limitedMode := (lvopts.Tail > 0) && (!lvopts.Follow) + limitedNum := lvopts.Tail + var stop bool + var watcher *fsnotify.Watcher + baseName := filepath.Base(jsonLogFilePath) + dir := filepath.Dir(jsonLogFilePath) + retryTimes := 2 + backBytes := 0 + + for { + select { + case <-stopChannel: + log.L.Debug("received stop signal while re-reading JSON logfile, returning") + return nil + default: + if stop || (limitedMode && limitedNum == 0) { + log.L.Debugf("finished parsing log JSON filefile, path: %s", jsonLogFilePath) return nil - default: - // Re-open the file and seek to the last-consumed offset. - fin, err = os.OpenFile(jsonLogFilePath, os.O_RDONLY, 0400) - if err != nil { - fin.Close() - return fmt.Errorf("error occurred while trying to re-open JSON logfile %q: %s", jsonLogFilePath, err) + } + + if line, err := jsonfile.Decode(stdout, stderr, fin, lvopts.Timestamps, lvopts.Since, lvopts.Until); err != nil { + if len(line) > 0 { + time.Sleep(5 * time.Millisecond) + if retryTimes == 0 { + log.L.Infof("finished parsing log JSON filefile, path: %s, line: %s", jsonLogFilePath, string(line)) + return fmt.Errorf("error occurred while doing read of JSON logfile %q: %s, retryTimes: %d", jsonLogFilePath, err, retryTimes) + } + retryTimes-- + backBytes = len(line) + } else { + return fmt.Errorf("error occurred while doing read of JSON logfile %q: %s", jsonLogFilePath, err) } - _, err = fin.Seek(lastPos, 0) + } else { + retryTimes = 2 + backBytes = 0 + } + + if lvopts.Follow { + // Get the current file handler's seek. + lastPos, err := fin.Seek(int64(-backBytes), io.SeekCurrent) if err != nil { - fin.Close() return fmt.Errorf("error occurred while trying to seek JSON logfile %q at position %d: %s", jsonLogFilePath, lastPos, err) } - err = jsonfile.Decode(stdout, stderr, fin, lvopts.Timestamps, lvopts.Since, lvopts.Until, 0) - if err != nil { - fin.Close() - return fmt.Errorf("error occurred while doing follow-up decoding of JSON logfile %q at starting position %d: %s", jsonLogFilePath, lastPos, err) + if watcher == nil { + // Initialize the watcher if it has not been initialized yet. + if watcher, err = NewLogFileWatcher(dir); err != nil { + return err + } + defer watcher.Close() + // If we just created the watcher, try again to read as we might have missed + // the event. + continue } - // Record current file seek position before looping again. - lastPos, err = fin.Seek(0, io.SeekCurrent) + var recreated bool + // Wait until the next log change. + recreated, err = startTail(context.Background(), baseName, watcher) if err != nil { + return err + } + if recreated { + newF, err := openFileShareDelete(jsonLogFilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + //If the user application outputs logs too quickly, + //There is a slight possibility that nerdctl has just rotated the log file, + //try opening it once more. + time.Sleep(10 * time.Millisecond) + } + newF, err = openFileShareDelete(jsonLogFilePath) + if err != nil { + return fmt.Errorf("failed to open JSON logfile %q: %w", jsonLogFilePath, err) + } + } fin.Close() - return fmt.Errorf("error occurred while trying to seek JSON logfile %q at current position: %s", jsonLogFilePath, err) + fin = newF } - fin.Close() + continue } + stop = true // Give the OS a second to breathe before re-opening the file: - time.Sleep(time.Second) } } - return nil -} - -// Loads logs through the `tail` executable. -func viewLogsJSONFileThroughTailExec(lvopts LogViewOptions, jsonLogFilePath string, stdout, stderr io.Writer, stopChannel chan os.Signal) error { - var args []string - - args = append(args, "-n") - if lvopts.Tail == 0 { - args = append(args, "+0") - } else { - args = append(args, fmt.Sprintf("%d", lvopts.Tail)) - } - - if lvopts.Follow { - args = append(args, "-f") - } - args = append(args, jsonLogFilePath) - cmd := exec.Command("tail", args...) - cmd.Stderr = os.Stderr - r, err := cmd.StdoutPipe() - if err != nil { - return err - } - if err := cmd.Start(); err != nil { - return err - } - - // Setup killing goroutine: - go func() { - <-stopChannel - logrus.Debugf("killing tail logs process with PID: %d", cmd.Process.Pid) - cmd.Process.Kill() - }() - - return jsonfile.Decode(stdout, stderr, r, lvopts.Timestamps, lvopts.Since, lvopts.Until, 0) } diff --git a/pkg/logging/json_logger_test.go b/pkg/logging/json_logger_test.go new file mode 100644 index 00000000000..7d0be36285d --- /dev/null +++ b/pkg/logging/json_logger_test.go @@ -0,0 +1,177 @@ +/* + 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 logging + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestReadRotatedJSONLog(t *testing.T) { + tmpDir := t.TempDir() + if runtime.GOOS == "windows" { + t.Skip("windows implementation does not seem to work right now and should be fixed: https://github.com/containerd/nerdctl/issues/3554") + } + file, err := os.CreateTemp(tmpDir, "logfile") + if err != nil { + t.Errorf("unable to create temp file, error: %s", err.Error()) + } + stdoutBuf := &bytes.Buffer{} + stderrBuf := &bytes.Buffer{} + containerStopped := make(chan os.Signal) + // Start to follow the container's log. + fileName := file.Name() + go func() { + lvOpts := LogViewOptions{ + Follow: true, + LogPath: fileName, + } + viewLogsJSONFileDirect(lvOpts, file.Name(), stdoutBuf, stderrBuf, containerStopped) + }() + + // log in stdout + expectedStdout := "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n" + dir := filepath.Dir(file.Name()) + baseName := filepath.Base(file.Name()) + + // Write 10 lines to log file. + // Let ReadLogs start. + time.Sleep(50 * time.Millisecond) + + type logContent struct { + Log string `json:"log"` + Stream string `json:"stream"` + Time string `json:"time"` + } + + for line := 0; line < 10; line++ { + // Write the first three lines to log file + log := logContent{} + log.Log = fmt.Sprintf("line%d\n", line) + log.Stream = "stdout" + log.Time = time.Now().Format(time.RFC3339Nano) + time.Sleep(1 * time.Millisecond) + logData, _ := json.Marshal(log) + file.Write(logData) + + if line == 5 { + file.Close() + // Pretend to rotate the log. + rotatedName := fmt.Sprintf("%s.%s", baseName, time.Now().Format("20060102-150405")) + rotatedName = filepath.Join(dir, rotatedName) + if err := os.Rename(filepath.Join(dir, baseName), rotatedName); err != nil { + t.Errorf("failed to rotate log %q to %q, error: %s", file.Name(), rotatedName, err.Error()) + return + } + + time.Sleep(20 * time.Millisecond) + newF := filepath.Join(dir, baseName) + if file, err = os.Create(newF); err != nil { + t.Errorf("unable to create new log file, error: %s", err.Error()) + return + } + } + } + + // Finished writing into the file, close it, so we can delete it later. + err = file.Close() + if err != nil { + t.Errorf("could not close file, error: %s", err.Error()) + } + + time.Sleep(2 * time.Second) + // Make the function ReadLogs end. + close(containerStopped) + + if expectedStdout != stdoutBuf.String() { + t.Errorf("expected: %s, acoutal: %s", expectedStdout, stdoutBuf.String()) + } +} + +func TestReadJSONLogs(t *testing.T) { + file, err := os.CreateTemp("", "TestFollowLogs") + if err != nil { + t.Fatalf("unable to create temp file") + } + defer os.Remove(file.Name()) + file.WriteString(`{"log":"line1\n","stream":"stdout","time":"2024-07-12T03:09:24.916296732Z"}` + "\n") + file.WriteString(`{"log":"line2\n","stream":"stdout","time":"2024-07-12T03:09:24.916296732Z"}` + "\n") + file.WriteString(`{"log":"line3\n","stream":"stdout","time":"2024-07-12T03:09:24.916296732Z"}` + "\n") + + stopChan := make(chan os.Signal) + testCases := []struct { + name string + logViewOptions LogViewOptions + expected string + }{ + { + name: "default log options should output all lines", + logViewOptions: LogViewOptions{ + LogPath: file.Name(), + Tail: 0, + }, + expected: "line1\nline2\nline3\n", + }, + { + name: "using Tail 2 should output last 2 lines", + logViewOptions: LogViewOptions{ + LogPath: file.Name(), + Tail: 2, + }, + expected: "line2\nline3\n", + }, + { + name: "using Tail 4 should output all lines when the log has less than 4 lines", + logViewOptions: LogViewOptions{ + LogPath: file.Name(), + Tail: 4, + }, + expected: "line1\nline2\nline3\n", + }, + { + name: "using Tail 0 should output all", + logViewOptions: LogViewOptions{ + LogPath: file.Name(), + Tail: 0, + }, + expected: "line1\nline2\nline3\n", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + stdoutBuf := bytes.NewBuffer(nil) + stderrBuf := bytes.NewBuffer(nil) + err = viewLogsJSONFileDirect(tc.logViewOptions, file.Name(), stdoutBuf, stderrBuf, stopChan) + + if err != nil { + t.Fatal(err.Error()) + } + if stderrBuf.Len() > 0 { + t.Fatalf("Stderr: %v", stderrBuf.String()) + } + if actual := stdoutBuf.String(); tc.expected != actual { + t.Fatalf("Actual output does not match expected.\nActual: %v\nExpected: %v\n", actual, tc.expected) + } + }) + } +} diff --git a/pkg/logging/jsonfile/jsonfile.go b/pkg/logging/jsonfile/jsonfile.go index cf5400daca5..2e47b836819 100644 --- a/pkg/logging/jsonfile/jsonfile.go +++ b/pkg/logging/jsonfile/jsonfile.go @@ -17,7 +17,6 @@ package jsonfile import ( - "container/ring" "encoding/json" "fmt" "io" @@ -29,7 +28,7 @@ import ( timetypes "github.com/docker/docker/api/types/time" - "github.com/sirupsen/logrus" + "github.com/containerd/log" ) // Entry is compatible with Docker "json-file" logs @@ -54,14 +53,14 @@ func Encode(stdout <-chan string, stderr <-chan string, writer io.Writer) error e := &Entry{ Stream: name, } - for log := range dataChan { - e.Log = log + "\n" + for logEntry := range dataChan { + e.Log = logEntry + "\n" e.Time = time.Now().UTC() encMu.Lock() encErr := enc.Encode(e) encMu.Unlock() if encErr != nil { - logrus.WithError(encErr).Errorf("failed to encode JSON") + log.L.WithError(encErr).Errorf("failed to encode JSON") return } } @@ -119,7 +118,7 @@ func writeEntry(e *Entry, stdout, stderr io.Writer, refTime time.Time, timestamp case "stderr": writeTo = stderr default: - logrus.Errorf("unknown stream name %q, entry=%+v", e.Stream, e) + log.L.Errorf("unknown stream name %q, entry=%+v", e.Stream, e) } if writeTo != nil { @@ -129,12 +128,7 @@ func writeEntry(e *Entry, stdout, stderr io.Writer, refTime time.Time, timestamp return nil } -func Decode(stdout, stderr io.Writer, r io.Reader, timestamps bool, since string, until string, tail uint) error { - var buff *ring.Ring - if tail != 0 { - buff = ring.New(int(tail)) - } - +func Decode(stdout, stderr io.Writer, r io.Reader, timestamps bool, since string, until string) ([]byte, error) { dec := json.NewDecoder(r) now := time.Now() for { @@ -142,41 +136,19 @@ func Decode(stdout, stderr io.Writer, r io.Reader, timestamps bool, since string if err := dec.Decode(&e); err == io.EOF { break } else if err != nil { - return err - } - - if buff == nil { - // Write out the entry directly - err := writeEntry(&e, stdout, stderr, now, timestamps, since, until) + line, err := io.ReadAll(dec.Buffered()) if err != nil { - logrus.Errorf("error while writing log entry to output stream: %s", err) + return nil, err } - } else { - // Else place the entry in a ring buffer - buff.Value = &e - buff = buff.Next() + return line, err } - } - if buff != nil { - // The ring should now contain up to `tail` elements and be set to - // internally point to the oldest element in the ring. - buff.Do(func(e interface{}) { - if e == nil { - // unallocated ring element - return - } - cast, ok := e.(*Entry) - if !ok { - logrus.Errorf("failed to cast Entry struct: %#v", e) - return - } - - err := writeEntry(cast, stdout, stderr, now, timestamps, since, until) - if err != nil { - logrus.Errorf("error while writing log entry to output stream: %s", err) - } - }) + // Write out the entry directly + err := writeEntry(&e, stdout, stderr, now, timestamps, since, until) + if err != nil { + log.L.Errorf("error while writing log entry to output stream: %s", err) + } } - return nil + + return nil, nil } diff --git a/pkg/logging/log_viewer.go b/pkg/logging/log_viewer.go index 80d0626ef6e..7cbc0292edf 100644 --- a/pkg/logging/log_viewer.go +++ b/pkg/logging/log_viewer.go @@ -23,8 +23,9 @@ import ( "os/exec" "path/filepath" - "github.com/containerd/nerdctl/pkg/labels/k8slabels" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/labels/k8slabels" ) // Type alias for functions which write out logs to the provided stdout/stderr Writers. @@ -37,7 +38,7 @@ var logViewers = make(map[string]LogViewerFunc) // Registers a LogViewerFunc for the func RegisterLogViewer(driverName string, lvfn LogViewerFunc) { if v, ok := logViewers[driverName]; ok { - logrus.Warnf("A LogViewerFunc with name %q has already been registered: %#v, overriding with %#v either way", driverName, v, lvfn) + log.L.Warnf("A LogViewerFunc with name %q has already been registered: %#v, overriding with %#v either way", driverName, v, lvfn) } logViewers[driverName] = lvfn } @@ -93,7 +94,7 @@ func (lvo *LogViewOptions) Validate() error { if err != nil { return err } - logrus.Warnf("given relative datastore path %q, transformed it to absolute path: %q", lvo.DatastoreRootPath, abs) + log.L.Warnf("given relative datastore path %q, transformed it to absolute path: %q", lvo.DatastoreRootPath, abs) lvo.DatastoreRootPath = abs } @@ -134,6 +135,10 @@ func InitContainerLogViewer(containerLabels map[string]string, lvopts LogViewOpt return nil, fmt.Errorf("the `cri` log viewer requires nerdctl to be running in experimental mode") } + if lcfg.Driver == "none" { + return nil, fmt.Errorf("log type `none` was selected, nothing to log") + } + lv := &ContainerLogViewer{ loggingConfig: lcfg, logViewingOptions: lvopts, diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index f81180503f0..0a5e57d5524 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -26,11 +26,16 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" + "time" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/runtime/v2/logging" - "github.com/sirupsen/logrus" + "github.com/fsnotify/fsnotify" + "github.com/muesli/cancelreader" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" + "github.com/containerd/errdefs" + "github.com/containerd/log" ) const ( @@ -44,16 +49,16 @@ const ( type Driver interface { Init(dataStore, ns, id string) error - PreProcess(dataStore string, config *logging.Config) error + PreProcess(ctx context.Context, dataStore string, config *logging.Config) error Process(stdout <-chan string, stderr <-chan string) error PostProcess() error } -type DriverFactory func(map[string]string) (Driver, error) -type LogOpsValidateFunc func(logOptMap map[string]string) error +type DriverFactory func(map[string]string, string) (Driver, error) +type LogOptsValidateFunc func(logOptMap map[string]string) error var drivers = make(map[string]DriverFactory) -var driversLogOptsValidateFunctions = make(map[string]LogOpsValidateFunc) +var driversLogOptsValidateFunctions = make(map[string]LogOptsValidateFunc) func ValidateLogOpts(logDriver string, logOpts map[string]string) error { if value, ok := driversLogOptsValidateFunctions[logDriver]; ok && value != nil { @@ -62,7 +67,7 @@ func ValidateLogOpts(logDriver string, logOpts map[string]string) error { return nil } -func RegisterDriver(name string, f DriverFactory, validateFunc LogOpsValidateFunc) { +func RegisterDriver(name string, f DriverFactory, validateFunc LogOptsValidateFunc) { drivers[name] = f driversLogOptsValidateFunctions[name] = validateFunc } @@ -76,25 +81,28 @@ func Drivers() []string { return ss } -func GetDriver(name string, opts map[string]string) (Driver, error) { +func GetDriver(name string, opts map[string]string, address string) (Driver, error) { driverFactory, ok := drivers[name] if !ok { return nil, fmt.Errorf("unknown logging driver %q: %w", name, errdefs.ErrNotFound) } - return driverFactory(opts) + return driverFactory(opts, address) } func init() { - RegisterDriver("json-file", func(opts map[string]string) (Driver, error) { + RegisterDriver("none", func(opts map[string]string, address string) (Driver, error) { + return &NoneLogger{}, nil + }, NoneLogOptsValidate) + RegisterDriver("json-file", func(opts map[string]string, address string) (Driver, error) { return &JSONLogger{Opts: opts}, nil }, JSONFileLogOptsValidate) - RegisterDriver("journald", func(opts map[string]string) (Driver, error) { - return &JournaldLogger{Opts: opts}, nil + RegisterDriver("journald", func(opts map[string]string, address string) (Driver, error) { + return &JournaldLogger{Opts: opts, Address: address}, nil }, JournalLogOptsValidate) - RegisterDriver("fluentd", func(opts map[string]string) (Driver, error) { + RegisterDriver("fluentd", func(opts map[string]string, address string) (Driver, error) { return &FluentdLogger{Opts: opts}, nil }, FluentdLogOptsValidate) - RegisterDriver("syslog", func(opts map[string]string) (Driver, error) { + RegisterDriver("syslog", func(opts map[string]string, address string) (Driver, error) { return &SyslogLogger{Opts: opts}, nil }, SyslogOptsValidate) } @@ -113,9 +121,10 @@ func Main(argv2 string) error { // LogConfig is marshalled as "log-config.json" type LogConfig struct { - Driver string `json:"driver"` - Opts map[string]string `json:"opts,omitempty"` - LogURI string `json:"-"` + Driver string `json:"driver"` + Opts map[string]string `json:"opts,omitempty"` + LogURI string `json:"-"` + Address string `json:"address"` } // LogConfigFilePath returns the path of log-config.json @@ -140,10 +149,25 @@ func LoadLogConfig(dataStore, ns, id string) (LogConfig, error) { return logConfig, nil } -func loggingProcessAdapter(driver Driver, dataStore string, config *logging.Config) error { - if err := driver.PreProcess(dataStore, config); err != nil { +func loggingProcessAdapter(ctx context.Context, driver Driver, dataStore string, config *logging.Config) error { + if err := driver.PreProcess(ctx, dataStore, config); err != nil { + return err + } + + stdoutR, err := cancelreader.NewReader(config.Stdout) + if err != nil { + return err + } + stderrR, err := cancelreader.NewReader(config.Stderr) + if err != nil { return err } + go func() { + <-ctx.Done() // delivered on SIGTERM + stdoutR.Cancel() + stderrR.Cancel() + }() + var wg sync.WaitGroup wg.Add(3) stdout := make(chan string, 10000) @@ -151,18 +175,25 @@ func loggingProcessAdapter(driver Driver, dataStore string, config *logging.Conf processLogFunc := func(reader io.Reader, dataChan chan string) { defer wg.Done() defer close(dataChan) - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - if scanner.Err() != nil { - logrus.Errorf("failed to read log: %v", scanner.Err()) - return + r := bufio.NewReader(reader) + + var err error + + for err == nil { + var s string + s, err = r.ReadString('\n') + + if len(s) > 0 { + dataChan <- strings.TrimSuffix(s, "\n") + } + + if err != nil && err != io.EOF { + log.L.WithError(err).Error("failed to read log") } - dataChan <- scanner.Text() } } - - go processLogFunc(config.Stdout, stdout) - go processLogFunc(config.Stderr, stderr) + go processLogFunc(stdoutR, stdout) + go processLogFunc(stderrR, stderr) go func() { defer wg.Done() driver.Process(stdout, stderr) @@ -175,7 +206,7 @@ func loggerFunc(dataStore string) (logging.LoggerFunc, error) { if dataStore == "" { return nil, errors.New("got empty data store") } - return func(_ context.Context, config *logging.Config, ready func() error) error { + return func(ctx context.Context, config *logging.Config, ready func() error) error { if config.Namespace == "" || config.ID == "" { return errors.New("got invalid config") } @@ -185,7 +216,7 @@ func loggerFunc(dataStore string) (logging.LoggerFunc, error) { if err != nil { return err } - driver, err := GetDriver(logConfig.Driver, logConfig.Opts) + driver, err := GetDriver(logConfig.Driver, logConfig.Opts, logConfig.Address) if err != nil { return err } @@ -193,7 +224,7 @@ func loggerFunc(dataStore string) (logging.LoggerFunc, error) { return err } - return loggingProcessAdapter(driver, dataStore, config) + return loggingProcessAdapter(ctx, driver, dataStore, config) } else if !errors.Is(err, os.ErrNotExist) { // the file does not exist if the container was created with nerdctl < 0.20 return err @@ -201,3 +232,45 @@ func loggerFunc(dataStore string) (logging.LoggerFunc, error) { return nil }, nil } + +func NewLogFileWatcher(dir string) (*fsnotify.Watcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create fsnotify watcher: %v", err) + } + if err = watcher.Add(dir); err != nil { + watcher.Close() + return nil, fmt.Errorf("failed to watch directory %q: %w", dir, err) + } + return watcher, nil +} + +// startTail wait for the next log write. +// the boolean value indicates if the log file was recreated; +// the error is error happens during waiting new logs. +func startTail(ctx context.Context, logName string, w *fsnotify.Watcher) (bool, error) { + errRetry := 5 + for { + select { + case <-ctx.Done(): + return false, fmt.Errorf("context cancelled") + case e := <-w.Events: + switch { + case e.Has(fsnotify.Write): + return false, nil + case e.Has(fsnotify.Create): + return filepath.Base(e.Name) == logName, nil + default: + log.L.Debugf("Received unexpected fsnotify event: %v, retrying", e) + } + case err := <-w.Errors: + log.L.Debugf("Received fsnotify watch error, retrying unless no more retries left, retries: %d, error: %s", errRetry, err) + if errRetry == 0 { + return false, err + } + errRetry-- + case <-time.After(logForceCheckPeriod): + return false, nil + } + } +} diff --git a/pkg/logging/logging_test.go b/pkg/logging/logging_test.go new file mode 100644 index 00000000000..175f1b3e64b --- /dev/null +++ b/pkg/logging/logging_test.go @@ -0,0 +1,115 @@ +/* + 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 logging + +import ( + "bufio" + "bytes" + "context" + "math/rand" + "strings" + "testing" + "time" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" +) + +type MockDriver struct { + processed bool + receivedStdout []string + receivedStderr []string +} + +func (m *MockDriver) Init(dataStore, ns, id string) error { + return nil +} + +func (m *MockDriver) PreProcess(ctx context.Context, dataStore string, config *logging.Config) error { + return nil +} + +func (m *MockDriver) Process(stdout <-chan string, stderr <-chan string) error { + for line := range stdout { + m.receivedStdout = append(m.receivedStdout, line) + } + for line := range stderr { + m.receivedStderr = append(m.receivedStderr, line) + } + m.processed = true + return nil +} + +func (m *MockDriver) PostProcess() error { + return nil +} + +func TestLoggingProcessAdapter(t *testing.T) { + // Will process a normal String to stdout and a bigger one to stderr + normalString := generateRandomString(1024) + + // Generate 64KB of random text of bufio MaxScanTokenSize + // https://github.com/containerd/nerdctl/issues/3343 + hugeString := generateRandomString(bufio.MaxScanTokenSize) + + // Prepare mock driver and logging config + driver := &MockDriver{} + stdoutBuffer := bytes.NewBufferString(normalString) + stderrBuffer := bytes.NewBufferString(hugeString) + config := &logging.Config{ + Stdout: stdoutBuffer, + Stderr: stderrBuffer, + } + + // Execute the logging process adapter + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := loggingProcessAdapter(ctx, driver, "testDataStore", config) + if err != nil { + t.Fatal(err) + } + + // let bufio read the buffer + time.Sleep(50 * time.Millisecond) + + // Verify that the driver methods were called + if !driver.processed { + t.Fatal("process should be processed") + } + + // Verify that the driver received the expected data + stdout := strings.Join(driver.receivedStdout, "\n") + stderr := strings.Join(driver.receivedStderr, "\n") + + if stdout != normalString { + t.Fatalf("stdout is %s, expected %s", stdout, normalString) + } + + if stderr != hugeString { + t.Fatalf("stderr is %s, expected %s", stderr, hugeString) + } +} + +// generateRandomString creates a random string of the given size. +func generateRandomString(size int) string { + characters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var sb strings.Builder + for i := 0; i < size; i++ { + sb.WriteByte(characters[rand.Intn(len(characters))]) + } + return sb.String() +} diff --git a/pkg/logging/logs_other.go b/pkg/logging/logs_other.go new file mode 100644 index 00000000000..94cd53ef2b2 --- /dev/null +++ b/pkg/logging/logs_other.go @@ -0,0 +1,33 @@ +//go:build !windows + +/* + 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. +*/ + +/* +Forked from https://github.com/kubernetes/kubernetes/blob/cc60b26dee4768e3c5aa0515bbf4ba1824ad38dc/staging/src/k8s.io/cri-client/pkg/logs/logs_other.go +Copyright The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 +*/ +package logging + +import ( + "os" +) + +func openFileShareDelete(path string) (*os.File, error) { + // Noop. Only relevant for Windows. + return os.Open(path) +} diff --git a/pkg/logging/logs_windows.go b/pkg/logging/logs_windows.go new file mode 100644 index 00000000000..c6902d5b52a --- /dev/null +++ b/pkg/logging/logs_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +/* + 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. +*/ + +/* +Forked from https://github.com/kubernetes/kubernetes/blob/cc60b26dee4768e3c5aa0515bbf4ba1824ad38dc/staging/src/k8s.io/cri-client/pkg/logs/logs_windows.go +Copyright The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 +*/ +package logging + +import ( + "os" + "syscall" +) + +// Based on Windows implementation of Windows' syscall.Open +// https://cs.opensource.google/go/go/+/refs/tags/go1.22.2:src/syscall/syscall_windows.go;l=342 +// In addition to syscall.Open, this function also adds the syscall.FILE_SHARE_DELETE flag to sharemode, +// which will allow us to read from the file without blocking the file from being deleted or renamed. +// This is essential for Log Rotation which is done by renaming the open file. Without this, the file rename would fail. +func openFileShareDelete(path string) (*os.File, error) { + pathp, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + + var access uint32 = syscall.GENERIC_READ + var sharemode uint32 = syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE + var createmode uint32 = syscall.OPEN_EXISTING + var attrs uint32 = syscall.FILE_ATTRIBUTE_NORMAL + + handle, err := syscall.CreateFile(pathp, access, sharemode, nil, createmode, attrs, 0) + if err != nil { + return nil, err + } + + return os.NewFile(uintptr(handle), path), nil +} diff --git a/pkg/logging/none_logger.go b/pkg/logging/none_logger.go new file mode 100644 index 00000000000..8d316c32465 --- /dev/null +++ b/pkg/logging/none_logger.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 logging + +import ( + "context" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" +) + +type NoneLogger struct { + Opts map[string]string +} + +func (n *NoneLogger) Init(dataStore, ns, id string) error { + return nil +} + +func (n *NoneLogger) PreProcess(ctx context.Context, dataStore string, config *logging.Config) error { + return nil +} + +func (n *NoneLogger) Process(stdout <-chan string, stderr <-chan string) error { + return nil +} + +func (n *NoneLogger) PostProcess() error { + return nil +} + +func NoneLogOptsValidate(_ map[string]string) error { + return nil +} diff --git a/pkg/logging/none_logger_test.go b/pkg/logging/none_logger_test.go new file mode 100644 index 00000000000..59e967bc5e1 --- /dev/null +++ b/pkg/logging/none_logger_test.go @@ -0,0 +1,74 @@ +/* + 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 logging + +import ( + "context" + "os" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/containerd/v2/core/runtime/v2/logging" +) + +func TestNoneLogger(t *testing.T) { + // Create a temporary directory for potential log files + tmpDir := t.TempDir() + ctx := context.Background() + + logger := &NoneLogger{ + Opts: map[string]string{}, + } + + t.Run("NoLoggingOccurs", func(t *testing.T) { + initialFiles, err := os.ReadDir(tmpDir) + assert.NilError(t, err, "Failed to read temp dir") + + // Run all logger methods + logger.Init(tmpDir, "namespace", "id") + logger.PreProcess(ctx, tmpDir, &logging.Config{}) + + stdout := make(chan string) + stderr := make(chan string) + + go func() { + for i := 0; i < 10; i++ { + stdout <- "test stdout" + stderr <- "test stderr" + } + close(stdout) + close(stderr) + }() + + err = logger.Process(stdout, stderr) + assert.NilError(t, err, "Process() returned unexpected error") + + logger.PostProcess() + + // Wait a bit to ensure any potential writes would have occurred + time.Sleep(100 * time.Millisecond) + + // Check if any new files were created + afterFiles, err := os.ReadDir(tmpDir) + assert.NilError(t, err, "Failed to read temp dir after operations") + + assert.Equal(t, len(afterFiles), len(initialFiles), "Expected no new files, but directory content changed") + + }) +} diff --git a/pkg/logging/syslog_logger.go b/pkg/logging/syslog_logger.go index 16cf62662f0..460caf5313a 100644 --- a/pkg/logging/syslog_logger.go +++ b/pkg/logging/syslog_logger.go @@ -17,6 +17,7 @@ package logging import ( + "context" "crypto/tls" "errors" "fmt" @@ -30,9 +31,10 @@ import ( "github.com/docker/go-connections/tlsconfig" syslog "github.com/yuchanns/srslog" - "github.com/containerd/containerd/runtime/v2/logging" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/core/runtime/v2/logging" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/strutil" ) const ( @@ -91,7 +93,7 @@ const ( func SyslogOptsValidate(logOptMap map[string]string) error { for key := range logOptMap { if !strutil.InStringSlice(syslogOpts, key) { - logrus.Warnf("log-opt %s is ignored for syslog log driver", key) + log.L.Warnf("log-opt %s is ignored for syslog log driver", key) } } proto, _, err := parseSyslogAddress(logOptMap[syslogAddress]) @@ -121,7 +123,7 @@ func (sy *SyslogLogger) Init(dataStore string, ns string, id string) error { return nil } -func (sy *SyslogLogger) PreProcess(dataStore string, config *logging.Config) error { +func (sy *SyslogLogger) PreProcess(ctx context.Context, dataStore string, config *logging.Config) error { logger, err := parseSyslog(config.ID, sy.Opts) if err != nil { return err diff --git a/pkg/mountutil/mountutil.go b/pkg/mountutil/mountutil.go index 4e44d0bf06a..d55a2cb6646 100644 --- a/pkg/mountutil/mountutil.go +++ b/pkg/mountutil/mountutil.go @@ -17,27 +17,30 @@ package mountutil import ( - "errors" "fmt" + "os" "path/filepath" "runtime" "strings" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/oci" - "github.com/containerd/containerd/pkg/userns" - "github.com/containerd/nerdctl/pkg/idgen" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" - "github.com/containerd/nerdctl/pkg/strutil" + "github.com/moby/sys/userns" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/idgen" + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) const ( - Bind = "bind" - Volume = "volume" - Tmpfs = "tmpfs" + Bind = "bind" + Volume = "volume" + Tmpfs = "tmpfs" + Npipe = "npipe" + pathSeparator = string(os.PathSeparator) ) type Processed struct { @@ -49,79 +52,84 @@ type Processed struct { Opts []oci.SpecOpts } -func ProcessFlagV(s string, volStore volumestore.VolumeStore) (*Processed, error) { +type volumeSpec struct { + Type string + Name string + Source string + AnonymousVolume string +} + +func ProcessFlagV(s string, volStore volumestore.VolumeStore, createDir bool) (*Processed, error) { var ( - res Processed + res *Processed + volSpec volumeSpec src, dst string options []string ) - s = strings.TrimLeft(s, ":") - split := strings.Split(s, ":") + split, err := splitVolumeSpec(s) + if err != nil { + return nil, fmt.Errorf("failed to split volume mount specification: %v", err) + } + switch len(split) { case 1: - dst = s - res.AnonymousVolume = idgen.GenerateID() - logrus.Debugf("creating anonymous volume %q, for %q", res.AnonymousVolume, s) - anonVol, err := volStore.Create(res.AnonymousVolume, []string{}) + // validate destination + dst = split[0] + if _, err := validateAnonymousVolumeDestination(dst); err != nil { + return nil, err + } + + // create anonymous volume + volSpec, err = handleAnonymousVolumes(dst, volStore) if err != nil { - return nil, fmt.Errorf("failed to create an anonymous volume %q: %w", res.AnonymousVolume, err) + return nil, err + } + + src = volSpec.Source + res = &Processed{ + Type: volSpec.Type, + AnonymousVolume: volSpec.AnonymousVolume, } - src = anonVol.Mountpoint - res.Type = Volume case 2, 3: - res.Type = Bind - src, dst = split[0], split[1] - if !strings.Contains(src, "/") { - // assume src is a volume name - res.Name = src - vol, err := volStore.Get(src, false) - if err != nil { - if errors.Is(err, errdefs.ErrNotFound) { - vol, err = volStore.Create(src, nil) - if err != nil { - return nil, fmt.Errorf("failed to create volume %q: %w", src, err) - } - } else { - return nil, fmt.Errorf("failed to get volume %q: %w", src, err) - } - } - // src is now full path - src = vol.Mountpoint - res.Type = Volume + // Vaildate destination + dst = split[1] + dst = strings.TrimLeft(dst, ":") + if _, err := isValidPath(dst); err != nil { + return nil, err } - if !filepath.IsAbs(src) { - logrus.Warnf("expected an absolute path, got a relative path %q (allowed for nerdctl, but disallowed for Docker, so unrecommended)", src) - var err error - src, err = filepath.Abs(src) - if err != nil { - return nil, fmt.Errorf("failed to get the absolute path of %q: %w", src, err) - } + + // Get volume spec + src = split[0] + volSpec, err = handleVolumeToMount(src, dst, volStore, createDir) + if err != nil { + return nil, err } - if !filepath.IsAbs(dst) { - return nil, fmt.Errorf("expected an absolute path, got %q", dst) + + src = volSpec.Source + res = &Processed{ + Type: volSpec.Type, + Name: volSpec.Name, + AnonymousVolume: volSpec.AnonymousVolume, } - rawOpts := "" + + // Parse volume options if len(split) == 3 { - rawOpts = split[2] - } - res.Mode = rawOpts + res.Mode = split[2] - // always call parseVolumeOptions for bind mount to allow the parser to add some default options - var err error - var specOpts []oci.SpecOpts - options, specOpts, err = parseVolumeOptions(res.Type, src, rawOpts) - if err != nil { - return nil, fmt.Errorf("failed to parse volume options (%q, %q, %q): %w", res.Type, src, rawOpts, err) + rawOpts := res.Mode + + options, res.Opts, err = getVolumeOptions(src, res.Type, rawOpts) + if err != nil { + return nil, err + } } - res.Opts = append(res.Opts, specOpts...) default: return nil, fmt.Errorf("failed to parse %q", s) } - fstype := "nullfs" + fstype := DefaultMountType if runtime.GOOS != "freebsd" { - fstype = "none" found := false for _, opt := range options { switch opt { @@ -139,8 +147,8 @@ func ProcessFlagV(s string, volStore volumestore.VolumeStore) (*Processed, error } res.Mount = specs.Mount{ Type: fstype, - Source: src, - Destination: dst, + Source: cleanMount(src), + Destination: cleanMount(dst), Options: options, } if userns.RunningInUserNS() { @@ -150,5 +158,110 @@ func ProcessFlagV(s string, volStore volumestore.VolumeStore) (*Processed, error } res.Mount.Options = strutil.DedupeStrSlice(append(res.Mount.Options, unpriv...)) } - return &res, nil + + return res, nil +} + +func handleBindMounts(source string, createDir bool) (volumeSpec, error) { + var res volumeSpec + res.Type = Bind + res.Source = source + + // Handle relative paths + if !filepath.IsAbs(source) { + absPath, err := filepath.Abs(source) + if err != nil { + return res, fmt.Errorf("failed to get the absolute path of %q: %w", source, err) + } + res.Source = absPath + } + + // Create dir if it does not exist + if err := createDirOnHost(source, createDir); err != nil { + return res, err + } + + return res, nil +} + +func handleAnonymousVolumes(s string, volStore volumestore.VolumeStore) (volumeSpec, error) { + var res volumeSpec + res.AnonymousVolume = idgen.GenerateID() + + log.L.Debugf("creating anonymous volume %q, for %q", res.AnonymousVolume, s) + anonVol, err := volStore.CreateWithoutLock(res.AnonymousVolume, []string{}) + if err != nil { + return res, fmt.Errorf("failed to create an anonymous volume %q: %w", res.AnonymousVolume, err) + } + + res.Type = Volume + res.Source = anonVol.Mountpoint + return res, nil +} + +func handleNamedVolumes(source string, volStore volumestore.VolumeStore) (volumeSpec, error) { + var res volumeSpec + res.Name = source + + // Create returns an existing volume or creates a new one if necessary. + vol, err := volStore.CreateWithoutLock(res.Name, nil) + if err != nil { + return res, fmt.Errorf("failed to get volume %q: %w", res.Name, err) + } + // src is now an absolute path + res.Type = Volume + res.Source = vol.Mountpoint + + return res, nil +} + +func getVolumeOptions(src string, vType string, rawOpts string) ([]string, []oci.SpecOpts, error) { + // always call parseVolumeOptions for bind mount to allow the parser to add some default options + var err error + var specOpts []oci.SpecOpts + options, specOpts, err := parseVolumeOptions(vType, src, rawOpts) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse volume options (%q, %q, %q): %w", vType, src, rawOpts, err) + } + + specOpts = append(specOpts, specOpts...) + return options, specOpts, nil +} + +func createDirOnHost(src string, createDir bool) error { + _, err := os.Stat(src) + if err == nil { + return nil + } + + if !createDir { + + /** + * In pkg\mountutil\mountutil_linux.go:432, we disallow creating directories on host if not found + * The user gets an error if the directory does not exist: + * error mounting "/foo" to rootfs at "/foo": stat /foo: no such file or directory: unknown. + * We log this error to give the user a hint that they may need to create the directory on the host. + * https://docs.docker.com/storage/bind-mounts/ + */ + if os.IsNotExist(err) { + log.L.Warnf("mount source %q does not exist. Please make sure to create the directory on the host.", src) + return nil + } + return fmt.Errorf("failed to stat %q: %w", src, err) + } + + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat %q: %w", src, err) + } + if err := os.MkdirAll(src, 0o755); err != nil { + return fmt.Errorf("failed to mkdir %q: %w", src, err) + } + return nil +} + +func isNamedVolume(s string) bool { + err := identifiers.ValidateDockerCompat(s) + + // If the volume name is invalid, we assume it is a path + return err == nil } diff --git a/pkg/mountutil/mountutil_freebsd.go b/pkg/mountutil/mountutil_freebsd.go index 76766d6a3db..58b32075b82 100644 --- a/pkg/mountutil/mountutil_freebsd.go +++ b/pkg/mountutil/mountutil_freebsd.go @@ -20,10 +20,18 @@ import ( "fmt" "strings" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" +) + +const ( + DefaultMountType = "nullfs" + + // FreeBSD doesn't support bind mounts. + DefaultPropagationMode = "" ) func UnprivilegedMountFlags(path string) ([]string, error) { @@ -31,9 +39,6 @@ func UnprivilegedMountFlags(path string) ([]string, error) { return m, nil } -// FreeBSD doesn't support bind mounts. -const DefaultPropagationMode = "" - // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { @@ -47,7 +52,7 @@ func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, e case "": // NOP default: - logrus.Warnf("unsupported volume option %q", opt) + log.L.Warnf("unsupported volume option %q", opt) } } var opts []string diff --git a/pkg/mountutil/mountutil_linux.go b/pkg/mountutil/mountutil_linux.go index 68940ab9a4e..a6a79d8e963 100644 --- a/pkg/mountutil/mountutil_linux.go +++ b/pkg/mountutil/mountutil_linux.go @@ -25,15 +25,17 @@ import ( "strconv" "strings" - "github.com/containerd/containerd/containers" - "github.com/containerd/containerd/mount" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" "github.com/docker/go-units" mobymount "github.com/moby/sys/mount" "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + + "github.com/containerd/containerd/v2/core/containers" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" ) /* @@ -44,6 +46,15 @@ import ( NOTICE: https://github.com/moby/moby/blob/v20.10.5/NOTICE */ +const ( + DefaultMountType = "none" + + // DefaultPropagationMode is the default propagation of mounts + // where user doesn't specify mount propagation explicitly. + // See also: https://github.com/moby/moby/blob/v20.10.7/volume/mounts/linux_parser.go#L145 + DefaultPropagationMode = "rprivate" +) + // UnprivilegedMountFlags is from https://github.com/moby/moby/blob/v20.10.5/daemon/oci_linux.go#L420-L450 // // Get the set of mount flags that are set on the mount that contains the given @@ -78,11 +89,6 @@ func UnprivilegedMountFlags(path string) ([]string, error) { return flags, nil } -// DefaultPropagationMode is the default propagation of mounts -// where user doesn't specify mount propagation explicitly. -// See also: https://github.com/moby/moby/blob/v20.10.7/volume/mounts/linux_parser.go#L145 -const DefaultPropagationMode = "rprivate" - // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { @@ -118,7 +124,7 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun case "": // NOP default: - logrus.Warnf("unsupported volume option %q", opt) + log.L.Warnf("unsupported volume option %q", opt) } } @@ -144,7 +150,7 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun // Older version of runc just ignores "rro", so we have to add "ro" too, to our best effort. opts = append(opts, "ro", "rro") if len(propagationRawOpts) != 1 || propagationRawOpts[0] != "rprivate" { - logrus.Warn("Mount option \"rro\" should be used in conjunction with \"rprivate\"") + log.L.Warn("Mount option \"rro\" should be used in conjunction with \"rprivate\"") } case "rw": // NOP @@ -418,13 +424,14 @@ func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, e } fieldsStr := strings.Join(fields, ":") - logrus.Debugf("Call legacy %s process, spec: %s ", mountType, fieldsStr) + log.L.Debugf("Call legacy %s process, spec: %s ", mountType, fieldsStr) switch mountType { case Tmpfs: return ProcessFlagTmpfs(fieldsStr) case Volume, Bind: - return ProcessFlagV(fieldsStr, volStore) + // createDir=false for --mount option to disallow creating directories on host if not found + return ProcessFlagV(fieldsStr, volStore, false) } return nil, fmt.Errorf("invalid mount type '%s' must be a volume/bind/tmpfs", mountType) } diff --git a/pkg/mountutil/mountutil_linux_test.go b/pkg/mountutil/mountutil_linux_test.go index 4ecf1f3f458..80e21542cea 100644 --- a/pkg/mountutil/mountutil_linux_test.go +++ b/pkg/mountutil/mountutil_linux_test.go @@ -21,11 +21,12 @@ import ( "strings" "testing" - "github.com/containerd/containerd/mount" - "github.com/containerd/containerd/oci" "github.com/opencontainers/runtime-spec/specs-go" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/oci" ) // TestParseVolumeOptions tests volume options are parsed as expected. @@ -206,3 +207,144 @@ func TestProcessTmpfs(t *testing.T) { assert.DeepEqual(t, expected, x.Mount.Options) } } + +func TestProcessFlagV(t *testing.T) { + tests := []struct { + rawSpec string + wants *Processed + err string + }{ + // Bind volumes: absolute path + { + rawSpec: "/mnt/foo:/mnt/foo:ro", + wants: &Processed{ + Type: "bind", + Mount: specs.Mount{ + Type: "none", + Destination: `/mnt/foo`, + Source: `/mnt/foo`, + Options: []string{"ro", "rprivate", "rbind"}, + }}, + }, + // Bind volumes: relative path + { + rawSpec: `./TestVolume/Path:/mnt/foo`, + wants: &Processed{ + Type: "bind", + Mount: specs.Mount{ + Type: "none", + Source: "", // will not check source of relative paths + Destination: `/mnt/foo`, + Options: []string{"rbind"}, + }}, + }, + // Named volumes + { + rawSpec: `TestVolume:/mnt/foo`, + wants: &Processed{ + Type: "volume", + Name: "TestVolume", + Mount: specs.Mount{ + Type: "none", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: `/mnt/foo`, + Options: []string{"rbind"}, + }}, + }, + { + rawSpec: `/mnt/foo:TestVolume`, + err: "expected an absolute path, got \"TestVolume\"", + }, + { + rawSpec: `/mnt/foo:./foo`, + err: "expected an absolute path, got \"./foo\"", + }, + } + + for _, tt := range tests { + t.Run(tt.rawSpec, func(t *testing.T) { + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, false) + if err != nil { + assert.Error(t, err, tt.err) + return + } + + assert.Equal(t, processedVolSpec.Type, tt.wants.Type) + assert.Equal(t, processedVolSpec.Mount.Type, tt.wants.Mount.Type) + assert.Equal(t, processedVolSpec.Mount.Destination, tt.wants.Mount.Destination) + assert.DeepEqual(t, processedVolSpec.Mount.Options, tt.wants.Mount.Options) + + if tt.wants.Name != "" { + assert.Equal(t, processedVolSpec.Name, tt.wants.Name) + } + if tt.wants.Mount.Source != "" { + assert.Equal(t, processedVolSpec.Mount.Source, tt.wants.Mount.Source) + } + }) + } +} + +func TestProcessFlagVAnonymousVolumes(t *testing.T) { + tests := []struct { + rawSpec string + wants *Processed + err string + }{ + { + rawSpec: `/mnt/foo`, + wants: &Processed{ + Type: "volume", + Mount: specs.Mount{ + Type: "none", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: `/mnt/foo`, + }}, + }, + { + rawSpec: `./TestVolume/Path`, + wants: &Processed{ + Type: "volume", + Mount: specs.Mount{ + Type: "none", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: `TestVolume/Path`, // cleanpath() removes the leading "./". Since we are mocking the os.Stat() call, this is fine. + }}, + }, + { + rawSpec: "TestVolume", + wants: &Processed{ + Type: "volume", + Mount: specs.Mount{ + Type: "none", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: "TestVolume", + }}, + }, + { + rawSpec: `/mnt/foo::ro`, + err: "expected an absolute path, got \"\"", + }, + } + + for _, tt := range tests { + t.Run(tt.rawSpec, func(t *testing.T) { + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + if err != nil { + assert.ErrorContains(t, err, tt.err) + return + } + + assert.Equal(t, processedVolSpec.Type, tt.wants.Type) + assert.Assert(t, processedVolSpec.AnonymousVolume != "") + assert.Equal(t, processedVolSpec.Mount.Type, tt.wants.Mount.Type) + assert.Equal(t, processedVolSpec.Mount.Destination, tt.wants.Mount.Destination) + + if tt.wants.Mount.Source != "" { + assert.Equal(t, processedVolSpec.Mount.Source, tt.wants.Mount.Source) + } + + // for anonymous volumes, we want to make sure that the source is not the same as the destination + assert.Assert(t, processedVolSpec.Mount.Source != processedVolSpec.Mount.Destination) + }) + } +} diff --git a/pkg/mountutil/mountutil_test.go b/pkg/mountutil/mountutil_test.go new file mode 100644 index 00000000000..85f5ee3bff9 --- /dev/null +++ b/pkg/mountutil/mountutil_test.go @@ -0,0 +1,38 @@ +/* + 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 mountutil + +import ( + "runtime" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" +) + +type MockVolumeStore struct { + volumestore.VolumeStore +} + +func (mv *MockVolumeStore) CreateWithoutLock(name string, labels []string) (*native.Volume, error) { + if runtime.GOOS == "windows" { + return &native.Volume{Name: "test_volume", Mountpoint: "C:\\test\\directory"}, nil + } + return &native.Volume{Name: "test_volume", Mountpoint: "/test/volume"}, nil +} + +//nolint:unused +var mockVolumeStore = &MockVolumeStore{} diff --git a/pkg/mountutil/mountutil_unix.go b/pkg/mountutil/mountutil_unix.go new file mode 100644 index 00000000000..5bf7e4d2420 --- /dev/null +++ b/pkg/mountutil/mountutil_unix.go @@ -0,0 +1,66 @@ +//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 mountutil + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" +) + +func splitVolumeSpec(s string) ([]string, error) { + s = strings.TrimLeft(s, ":") + split := strings.Split(s, ":") + return split, nil +} + +func handleVolumeToMount(source string, dst string, volStore volumestore.VolumeStore, createDir bool) (volumeSpec, error) { + switch { + // Handle named volumes + case isNamedVolume(source): + return handleNamedVolumes(source, volStore) + + // Handle bind volumes (file paths) + default: + return handleBindMounts(source, createDir) + } +} + +func cleanMount(p string) string { + return filepath.Clean(p) +} + +func isValidPath(s string) (bool, error) { + if filepath.IsAbs(s) { + return true, nil + } + + return false, fmt.Errorf("expected an absolute path, got %q", s) +} + +/* +For docker compatibility on non-Windows platforms: +Docker allows anonymous named volumes, relative paths, and absolute paths +to be mounted into a container. +*/ +func validateAnonymousVolumeDestination(s string) (bool, error) { + return true, nil +} diff --git a/pkg/mountutil/mountutil_windows.go b/pkg/mountutil/mountutil_windows.go index 3867b8d10fc..e81c072f39d 100644 --- a/pkg/mountutil/mountutil_windows.go +++ b/pkg/mountutil/mountutil_windows.go @@ -14,16 +14,37 @@ limitations under the License. */ +/* + Portions from https://github.com/moby/moby/blob/f5c7673ff8fcbd359f75fb644b1365ca9d20f176/volume/mounts/windows_parser.go#L26 + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/master/NOTICE +*/ + package mountutil import ( "fmt" + "path/filepath" + "regexp" "strings" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/oci" - "github.com/containerd/nerdctl/pkg/mountutil/volumestore" - "github.com/sirupsen/logrus" + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/mountutil/volumestore" +) + +const ( + // Defaults to an empty string + // https://github.com/microsoft/hcsshim/blob/5c75f29c1f5cb4d3498d66228637d07477bcb6a1/internal/hcsoci/resources_wcow.go#L140 + DefaultMountType = "" + + // DefaultPropagationMode is the default propagation of mounts + // where user doesn't specify mount propagation explicitly. + // See also: https://github.com/moby/moby/blob/v20.10.7/volume/mounts/windows_parser.go#L440-L442 + DefaultPropagationMode = "" ) func UnprivilegedMountFlags(path string) ([]string, error) { @@ -31,11 +52,6 @@ func UnprivilegedMountFlags(path string) ([]string, error) { return m, nil } -// DefaultPropagationMode is the default propagation of mounts -// where user doesn't specify mount propagation explicitly. -// See also: https://github.com/moby/moby/blob/v20.10.7/volume/mounts/windows_parser.go#L440-L442 -const DefaultPropagationMode = "" - // parseVolumeOptions parses specified optsRaw with using information of // the volume type and the src directory when necessary. func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, error) { @@ -49,7 +65,7 @@ func parseVolumeOptions(vType, src, optsRaw string) ([]string, []oci.SpecOpts, e case "": // NOP default: - logrus.Warnf("unsupported volume option %q", opt) + log.L.Warnf("unsupported volume option %q", opt) } } var opts []string @@ -68,3 +84,173 @@ func ProcessFlagTmpfs(s string) (*Processed, error) { func ProcessFlagMount(s string, volStore volumestore.VolumeStore) (*Processed, error) { return nil, errdefs.ErrNotImplemented } + +func handleVolumeToMount(source string, dst string, volStore volumestore.VolumeStore, createDir bool) (volumeSpec, error) { + // Validate source and destination types + if _, err := (validateNamedPipeSpec(source, dst)); err != nil { + return volumeSpec{}, err + } + + switch { + // Handle named volumes + case isNamedVolume(source): + return handleNamedVolumes(source, volStore) + + // Handle named pipes + case isNamedPipe(source): + return handleNpipeToMount(source) + + // Handle bind volumes (file paths) + default: + return handleBindMounts(source, createDir) + } +} + +func handleNpipeToMount(source string) (volumeSpec, error) { + res := volumeSpec{ + Type: Npipe, + Source: source, + } + return res, nil +} + +func splitVolumeSpec(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + raw = strings.TrimLeft(raw, ":") + if raw == "" { + return nil, fmt.Errorf("invalid empty volume specification") + } + + const ( + // Root drive or relative paths starting with . + rxHostDir = `(?:[a-zA-Z]:|\.)[\/\\]` + + // https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats + // Windows UNC paths and DOS device paths (and namde pipes) + rxUNC = `(?:\\{2}[a-zA-Z0-9_\-\.\?]+\\{1}[^\\*?"|\r\n]+)\\` + rxName = `[^\/\\:*?"<>|\r\n]+` + + rxSource = `((?P((` + rxHostDir + `|` + rxUNC + `)` + `(` + rxName + `[\/\\]?)+` + `|` + rxName + `)):)?` + rxDestination = `(?P(` + rxHostDir + `|` + rxUNC + `)` + `(` + rxName + `[\/\\]?)+` + `|` + rxName + `)` + rxMode = `(?::(?P(?i)\w+(,\w+)?))` + + rxWindows = `^` + rxSource + rxDestination + `(?:` + rxMode + `)?$` + ) + + compiledRegex, err := regexp.Compile(rxWindows) + if err != nil { + return nil, fmt.Errorf("error compiling regex: %s", err) + } + return splitRawSpec(raw, compiledRegex) +} + +func isNamedPipe(s string) bool { + pattern := `^\\{2}.\\pipe\\[^\/\\:*?"<>|\r\n]+$` + matches, err := regexp.MatchString(pattern, s) + if err != nil { + log.L.Errorf("Invalid pattern %s", pattern) + } + + return matches +} + +func cleanMount(p string) string { + if isNamedPipe(p) { + return p + } + return filepath.Clean(p) +} + +func isValidPath(s string) (bool, error) { + if isNamedPipe(s) || filepath.IsAbs(s) { + return true, nil + } + + return false, fmt.Errorf("expected an absolute path or a named pipe, got %q", s) +} + +/* +For docker compatibility on Windows platforms: +Docker only allows for absolute paths as anonymous volumes. +Docker does not allows anonymous named volumes or anonymous named piped +to be mounted into a container. +*/ +func validateAnonymousVolumeDestination(s string) (bool, error) { + if isNamedPipe(s) || isNamedVolume(s) { + return false, fmt.Errorf("invalid volume specification: %q. only directories can be mapped as anonymous volumes", s) + } + + if filepath.IsAbs(s) { + return true, nil + } + + return false, fmt.Errorf("expected an absolute path, got %q", s) +} + +func splitRawSpec(raw string, splitRegexp *regexp.Regexp) ([]string, error) { + match := splitRegexp.FindStringSubmatch(raw) + if len(match) == 0 { + return nil, fmt.Errorf("invalid volume specification: '%s'", raw) + } + + var split []string + matchgroups := make(map[string]string) + // Pull out the sub expressions from the named capture groups + for i, name := range splitRegexp.SubexpNames() { + matchgroups[name] = match[i] + } + if source, exists := matchgroups["source"]; exists { + if source == "." { + return nil, fmt.Errorf("invalid volume specification: %q", raw) + } + + if source != "" { + split = append(split, source) + } + } + + mode, modExists := matchgroups["mode"] + + if destination, exists := matchgroups["destination"]; exists { + if destination == "." { + return nil, fmt.Errorf("invalid volume specification: %q", raw) + } + + // If mode exists and destination is empty, set destination to an empty string + // source::ro + if destination != "" || modExists && mode != "" { + split = append(split, destination) + } + } + + if mode, exists := matchgroups["mode"]; exists { + if mode != "" { + split = append(split, mode) + } + } + return split, nil +} + +// Function to parse the source type +func parseSourceType(source string) string { + switch { + case isNamedVolume(source): + return Volume + case isNamedPipe(source): + return Npipe + // Add more cases for different source types as needed + default: + return Bind + } +} + +func validateNamedPipeSpec(source string, dst string) (bool, error) { + // Validate source and destination types + sourceType := parseSourceType(source) + destType := parseSourceType(dst) + + if (destType == Npipe && sourceType != Npipe) || (sourceType == Npipe && destType != Npipe) { + return false, fmt.Errorf("invalid volume specification. named pipes can only be mapped to named pipes") + } + return true, nil +} diff --git a/pkg/mountutil/mountutil_windows_test.go b/pkg/mountutil/mountutil_windows_test.go index d016273ac3d..05428b113c5 100644 --- a/pkg/mountutil/mountutil_windows_test.go +++ b/pkg/mountutil/mountutil_windows_test.go @@ -17,9 +17,11 @@ package mountutil import ( + "fmt" "strings" "testing" + "github.com/opencontainers/runtime-spec/specs-go" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" ) @@ -36,7 +38,7 @@ func TestParseVolumeOptions(t *testing.T) { vType: "bind", src: "dummy", optsRaw: "rw", - wants: []string{}, + wants: nil, }, { vType: "volume", @@ -78,3 +80,266 @@ func TestParseVolumeOptions(t *testing.T) { }) } } + +func TestSplitRawSpec(t *testing.T) { + tests := []struct { + rawSpec string + wants []string + }{ + // Absolute paths + { + rawSpec: `C:\TestVolume\Path:C:\TestVolume\Path:ro`, + wants: []string{`C:\TestVolume\Path`, `C:\TestVolume\Path`, "ro"}, + }, + { + rawSpec: `C:\TestVolume\Path:C:\TestVolume\Path:ro,rw`, + wants: []string{`C:\TestVolume\Path`, `C:\TestVolume\Path`, "ro,rw"}, + }, + { + rawSpec: `C:\TestVolume\Path:C:\TestVolume\Path:ro,undefined`, + wants: []string{`C:\TestVolume\Path`, `C:\TestVolume\Path`, "ro,undefined"}, + }, + { + rawSpec: `C:\TestVolume\Path:C:\TestVolume\Path`, + wants: []string{`C:\TestVolume\Path`, `C:\TestVolume\Path`}, + }, + { + rawSpec: `C:\TestVolume\Path`, + wants: []string{`C:\TestVolume\Path`}, + }, + { + rawSpec: `C:\Test Volume\Path`, // space in path + wants: []string{`C:\Test Volume\Path`}, + }, + + // Relative paths + { + rawSpec: `.\ContainerVolumes:C:\TestVolumes`, + wants: []string{`.\ContainerVolumes`, `C:\TestVolumes`}, + }, + { + rawSpec: `.\ContainerVolumes:.\ContainerVolumes`, + wants: []string{`.\ContainerVolumes`, `.\ContainerVolumes`}, + }, + + // Anonymous volumes + { + rawSpec: `.\ContainerVolumes`, + wants: []string{`.\ContainerVolumes`}, + }, + { + rawSpec: `TestVolume`, + wants: []string{`TestVolume`}, + }, + { + rawSpec: `:TestVolume`, + wants: []string{`TestVolume`}, + }, + + // UNC paths + { + rawSpec: `\\?\UNC\server\share\path:.\ContainerVolumesto`, + wants: []string{`\\?\UNC\server\share\path`, `.\ContainerVolumesto`}, + }, + { + rawSpec: `\\.\Volume{b75e2c83-0000-0000-0000-602f00000000}\Test`, + wants: []string{`\\.\Volume{b75e2c83-0000-0000-0000-602f00000000}\Test`}, + }, + + // Named pipes + { + rawSpec: `\\.\pipe\containerd-containerd`, + wants: []string{`\\.\pipe\containerd-containerd`}, + }, + { + rawSpec: `\\.\pipe\containerd-containerd:\\.\pipe\containerd-containerd`, + wants: []string{`\\.\pipe\containerd-containerd`, `\\.\pipe\containerd-containerd`}, + }, + } + for _, tt := range tests { + t.Run(tt.rawSpec, func(t *testing.T) { + actual, err := splitVolumeSpec(tt.rawSpec) + if err != nil { + t.Errorf("failed to split raw spec %q: %v", tt.rawSpec, err) + return + + } + assert.Check(t, is.DeepEqual(tt.wants, actual)) + }) + } +} + +func TestSplitRawSpecInvalid(t *testing.T) { + tests := []string{ + "", // Empty string + " ", // Empty string + `.`, // Invalid relative path + `./`, // Invalid relative path + `../`, // Invalid relative path + `C:\`, // Cannot mount root directory + `~\TestVolume`, // Invalid relative path + `..\TestVolume`, // Invalid relative path + `ABC:\ContainerVolumes:C:\TestVolumes`, // Invalid drive letter + `UNC\server\share\path`, // Invalid path + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + _, err := splitVolumeSpec(path) + if strings.TrimSpace(path) == "" { + assert.Error(t, err, "invalid empty volume specification") + return + } + if path == "." { + assert.Error(t, err, "invalid volume specification: \".\"") + return + } + assert.Error(t, err, fmt.Sprintf("invalid volume specification: '%s'", path)) + }) + } +} + +func TestProcessFlagV(t *testing.T) { + tests := []struct { + rawSpec string + wants *Processed + err string + }{ + // Bind volumes: absolute path + { + rawSpec: "C:/TestVolume/Path:C:/TestVolume/Path:ro", + wants: &Processed{ + Type: "bind", + Mount: specs.Mount{ + Type: "", + Destination: `C:\TestVolume\Path`, + Source: `C:\TestVolume\Path`, + Options: []string{"ro", "rbind"}, + }}, + }, + // Bind volumes: relative path + { + rawSpec: `.\TestVolume\Path:C:\TestVolume\Path`, + wants: &Processed{ + Type: "bind", + Mount: specs.Mount{ + Type: "", + Source: "", // will not check source of relative paths + Destination: `C:\TestVolume\Path`, + Options: []string{"rbind"}, + }}, + }, + // Named volumes + { + rawSpec: `TestVolume:C:\TestVolume\Path`, + wants: &Processed{ + Type: "volume", + Name: "TestVolume", + Mount: specs.Mount{ + Type: "", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: `C:\TestVolume\Path`, + Options: []string{"rbind"}, + }}, + }, + // Named pipes + { + rawSpec: `\\.\pipe\containerd-containerd:\\.\pipe\containerd-containerd`, + wants: &Processed{ + Type: "npipe", + Mount: specs.Mount{ + Type: "", + Source: `\\.\pipe\containerd-containerd`, + Destination: `\\.\pipe\containerd-containerd`, + Options: []string{"rbind"}, + }}, + }, + { + rawSpec: `\\.\pipe\containerd-containerd:C:\TestVolume\Path`, + err: "invalid volume specification. named pipes can only be mapped to named pipes", + }, + { + rawSpec: `C:\TestVolume\Path:TestVolume`, + err: "expected an absolute path or a named pipe, got \"TestVolume\"", + }, + } + + for _, tt := range tests { + t.Run(tt.rawSpec, func(t *testing.T) { + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + if err != nil { + assert.Error(t, err, tt.err) + return + } + + assert.Equal(t, processedVolSpec.Type, tt.wants.Type) + assert.Equal(t, processedVolSpec.Mount.Type, tt.wants.Mount.Type) + assert.Equal(t, processedVolSpec.Mount.Destination, tt.wants.Mount.Destination) + assert.DeepEqual(t, processedVolSpec.Mount.Options, tt.wants.Mount.Options) + + if tt.wants.Name != "" { + assert.Equal(t, processedVolSpec.Name, tt.wants.Name) + } + if tt.wants.Mount.Source != "" { + assert.Equal(t, processedVolSpec.Mount.Source, tt.wants.Mount.Source) + } + }) + } +} + +func TestProcessFlagVAnonymousVolumes(t *testing.T) { + tests := []struct { + rawSpec string + wants *Processed + err string + }{ + { + rawSpec: `C:\TestVolume\Path`, + wants: &Processed{ + Type: "volume", + Mount: specs.Mount{ + Type: "", + Source: "", // source of anonymous volume is a generated path, so here will not check it. + Destination: `C:\TestVolume\Path`, + }}, + }, + { + rawSpec: `.\TestVolume\Path`, + err: "expected an absolute path", + }, + { + rawSpec: `TestVolume`, + err: "only directories can be mapped as anonymous volumes", + }, + { + rawSpec: `C:\TestVolume\Path::ro`, + err: "failed to split volume mount specification", + }, + { + rawSpec: `\\.\pipe\containerd-containerd`, + err: "only directories can be mapped as anonymous volumes", + }, + } + + for _, tt := range tests { + t.Run(tt.rawSpec, func(t *testing.T) { + processedVolSpec, err := ProcessFlagV(tt.rawSpec, mockVolumeStore, true) + if err != nil { + assert.ErrorContains(t, err, tt.err) + return + } + + assert.Equal(t, processedVolSpec.Type, tt.wants.Type) + assert.Assert(t, processedVolSpec.AnonymousVolume != "") + assert.Equal(t, processedVolSpec.Mount.Type, tt.wants.Mount.Type) + assert.Equal(t, processedVolSpec.Mount.Destination, tt.wants.Mount.Destination) + + if tt.wants.Mount.Source != "" { + assert.Equal(t, processedVolSpec.Mount.Source, tt.wants.Mount.Source) + } + + // for anonymous volumes, we want to make sure that the source is not the same as the destination + assert.Assert(t, processedVolSpec.Mount.Source != processedVolSpec.Mount.Destination) + }) + } +} diff --git a/pkg/mountutil/volumestore/volumestore.go b/pkg/mountutil/volumestore/volumestore.go index cf2d6a1bd1a..9dac3df604c 100644 --- a/pkg/mountutil/volumestore/volumestore.go +++ b/pkg/mountutil/volumestore/volumestore.go @@ -14,190 +14,378 @@ limitations under the License. */ +// Package volumestore allows manipulating containers' volumes. +// All methods are safe to use concurrently (and perform atomic writes), except CreateWithoutLock, which is specifically +// meant to be used multiple times, inside a Lock-ed section. package volumestore import ( "encoding/json" + "errors" "fmt" - "os" "path/filepath" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/lockutil" - "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/store" + "github.com/containerd/nerdctl/v2/pkg/strutil" ) -// Path returns a string like `/var/lib/nerdctl/1935db59/volumes/default`. -func Path(dataStore, ns string) (string, error) { - if dataStore == "" || ns == "" { - return "", errdefs.ErrInvalidArgument - } - volStore := filepath.Join(dataStore, "volumes", ns) - return volStore, nil +const ( + volumeDirBasename = "volumes" + dataDirName = "_data" + volumeJSONFileName = "volume.json" +) + +// ErrVolumeStore will wrap all errors here +var ErrVolumeStore = errors.New("volume-store error") + +type VolumeStore interface { + // Exists checks if a given volume exists + Exists(name string) (bool, error) + // Get returns an existing volume + Get(name string, size bool) (*native.Volume, error) + // Create will either return an existing volume, or create a new one + // NOTE that different labels will NOT create a new volume if there is one by that name already, + // but instead return the existing one with the (possibly different) labels + Create(name string, labels []string) (vol *native.Volume, err error) + // List returns all existing volumes. + // Note that list is expensive as it reads all volumes individual info + List(size bool) (map[string]native.Volume, error) + // Remove one of more volumes + Remove(generator func() ([]string, []error, error)) (removed []string, warns []error, err error) + // Prune will call a filtering function expected to return the volumes name to delete + Prune(filter func(volumes []*native.Volume) ([]string, error)) (err error) + // Count returns the number of volumes + Count() (count int, err error) + + // Lock: see store implementation + Lock() error + // CreateWithoutLock will create a volume (or return an existing one). + // This method does NOT lock (unlike Create). + // It is meant to be used between `Lock` and `Release`, and is specifically useful when multiple different volume + // creation will have to happen in different method calls (eg: container create). + CreateWithoutLock(name string, labels []string) (*native.Volume, error) + // Release: see store implementation + Release() error } // New returns a VolumeStore -func New(dataStore, ns string) (VolumeStore, error) { - volStoreDir, err := Path(dataStore, ns) +func New(dataStore, namespace string) (volStore VolumeStore, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + if dataStore == "" || namespace == "" { + return nil, store.ErrInvalidArgument + } + + st, err := store.New(filepath.Join(dataStore, volumeDirBasename, namespace), 0, 0o644) if err != nil { return nil, err } - if err := os.MkdirAll(volStoreDir, 0700); err != nil { - return nil, err + + return &volumeStore{ + Locker: st, + manager: st, + }, nil +} + +type volumeStore struct { + // Expose the lock primitives directly to satisfy interface for Lock and Release + store.Locker + + manager store.Manager +} + +// Exists checks if a volume exists in the store +func (vs *volumeStore) Exists(name string) (doesExist bool, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + if err = identifiers.ValidateDockerCompat(name); err != nil { + return false, err } - vs := &volumeStore{ - dir: volStoreDir, + + // No need for a lock here, the operation is atomic + return vs.manager.Exists(name) +} + +// Get retrieves a native volume from the store, optionally with its size +func (vs *volumeStore) Get(name string, size bool) (vol *native.Volume, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + if err = identifiers.ValidateDockerCompat(name); err != nil { + return nil, err } - return vs, nil + + // If we require the size, this is no longer atomic, so, we need to lock + err = vs.WithLock(func() error { + vol, err = vs.rawGet(name, size) + return err + }) + + return vol, err } -// DataDirName is "_data" -const DataDirName = "_data" +// CreateWithoutLock will create a new volume, or return an existing one if there is one already by that name +// It does NOT lock for you - unlike all the other methods - though it *will* error if you do not lock. +// This is on purpose as volume creation in most cases are done during container creation, +// and implies an extended period of time for locking. +// To use: +// volStore.Lock() +// defer volStore.Release() +// volStore.CreateWithoutLock(...) +func (vs *volumeStore) CreateWithoutLock(name string, labels []string) (vol *native.Volume, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() -const volumeJSONFileName = "volume.json" + if err = identifiers.ValidateDockerCompat(name); err != nil { + return nil, err + } -type VolumeStore interface { - Dir() string - Create(name string, labels []string) (*native.Volume, error) - // Get may return ErrNotFound - Get(name string, size bool) (*native.Volume, error) - List(size bool) (map[string]native.Volume, error) - Remove(names []string) (removedNames []string, err error) + return vs.rawCreate(name, labels) } -type volumeStore struct { - // dir is a string like `/var/lib/nerdctl/1935db59/volumes/default`. - // dir is guaranteed to exist. - dir string +func (vs *volumeStore) Create(name string, labels []string) (vol *native.Volume, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + if err = identifiers.ValidateDockerCompat(name); err != nil { + return nil, err + } + + err = vs.Locker.WithLock(func() error { + vol, err = vs.rawCreate(name, labels) + return err + }) + + return vol, err } -func (vs *volumeStore) Dir() string { - return vs.dir +func (vs *volumeStore) Count() (count int, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + err = vs.Locker.WithLock(func() error { + names, err := vs.manager.List() + if err != nil { + return err + } + + count = len(names) + return nil + }) + + return count, err } -func (vs *volumeStore) Create(name string, labels []string) (*native.Volume, error) { - if err := identifiers.Validate(name); err != nil { - return nil, fmt.Errorf("malformed name %s: %w", name, err) - } - volPath := filepath.Join(vs.dir, name) - volDataPath := filepath.Join(volPath, DataDirName) - fn := func() error { - if err := os.Mkdir(volPath, 0700); err != nil { +func (vs *volumeStore) List(size bool) (res map[string]native.Volume, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + res = make(map[string]native.Volume) + + err = vs.Locker.WithLock(func() error { + names, err := vs.manager.List() + if err != nil { return err } - if err := os.Mkdir(volDataPath, 0755); err != nil { + + for _, name := range names { + vol, err := vs.rawGet(name, size) + if err != nil { + log.L.WithError(err).Errorf("something is wrong with %q", name) + continue + } + res[name] = *vol + } + + return nil + }) + + return res, err +} + +// Remove will remove one or more containers +func (vs *volumeStore) Remove(generator func() ([]string, []error, error)) (removed []string, warns []error, err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) + } + }() + + err = vs.Locker.WithLock(func() error { + var names []string + names, warns, err = generator() + if err != nil { return err } - type volumeOpts struct { - Labels map[string]string `json:"labels"` + for _, name := range names { + // Invalid name, soft error + if err = identifiers.ValidateDockerCompat(name); err != nil { + // TODO: we are clearly mixing presentation concerns here + // This should be handled by the cli, not here + warns = append(warns, err) + continue + } + + // Erroring on Exists is a hard error + // !doesExist is a soft error + // Inability to delete is a hard error + if doesExist, err := vs.manager.Exists(name); err != nil { + return err + } else if !doesExist { + // TODO: see above + warns = append(warns, fmt.Errorf("volume %q: %w", name, store.ErrNotFound)) + continue + } else if err = vs.manager.Delete(name); err != nil { + return err + } + + // Otherwise, add it the list of successfully removed + removed = append(removed, name) } - labelsMap := strutil.ConvertKVStringsToMap(labels) + return nil + }) - volOpts := volumeOpts{ - Labels: labelsMap, + return removed, warns, err +} + +func (vs *volumeStore) Prune(filter func(vol []*native.Volume) ([]string, error)) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrVolumeStore, err) } + }() - labelsJSON, err := json.MarshalIndent(volOpts, "", " ") + return vs.Locker.WithLock(func() error { + names, err := vs.manager.List() if err != nil { return err } - volFilePath := filepath.Join(volPath, volumeJSONFileName) - if err := os.WriteFile(volFilePath, labelsJSON, 0644); err != nil { + res := []*native.Volume{} + for _, name := range names { + vol, err := vs.rawGet(name, false) + if err != nil { + log.L.WithError(err).Errorf("something is wrong with %q", name) + continue + } + res = append(res, vol) + } + + toDelete, err := filter(res) + if err != nil { return err } + + for _, name := range toDelete { + err = vs.manager.Delete(name) + if err != nil { + return err + } + } + return nil + }) +} + +func (vs *volumeStore) rawGet(name string, size bool) (vol *native.Volume, err error) { + content, err := vs.manager.Get(name, volumeJSONFileName) + if err != nil { + return nil, err + } + + vol = &native.Volume{ + Name: name, + Labels: labels(content), } - if err := lockutil.WithDirLock(vs.dir, fn); err != nil { + vol.Mountpoint, err = vs.manager.Location(name, dataDirName) + if err != nil { return nil, err } - vol := &native.Volume{ - Name: name, - Mountpoint: volDataPath, + if size { + vol.Size, err = vs.manager.GroupSize(name, dataDirName) + if err != nil { + return nil, errors.Join(fmt.Errorf("failed reading volume size for %q", name), err) + } } + return vol, nil } -func (vs *volumeStore) Get(name string, size bool) (*native.Volume, error) { - if err := identifiers.Validate(name); err != nil { - return nil, fmt.Errorf("malformed name %s: %w", name, err) +func (vs *volumeStore) rawCreate(name string, labels []string) (vol *native.Volume, err error) { + volOpts := struct { + Labels map[string]string `json:"labels"` + }{} + + if len(labels) > 0 { + volOpts.Labels = strutil.ConvertKVStringsToMap(labels) } - dataPath := filepath.Join(vs.dir, name, DataDirName) - if _, err := os.Stat(dataPath); err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("volume %q not found: %w", name, errdefs.ErrNotFound) - } + + // Failure here must exit, no need to clean-up + labelsJSON, err := json.MarshalIndent(volOpts, "", " ") + if err != nil { return nil, err } - volFilePath := filepath.Join(vs.dir, name, volumeJSONFileName) - volumeDataBytes, err := os.ReadFile(volFilePath) - if err != nil { - if os.IsNotExist(err) { - //volume.json does not exists should not be blocking for inspect operation - } else { + if doesExist, err := vs.manager.Exists(name, volumeJSONFileName); err != nil { + return nil, err + } else if !doesExist { + if err = vs.manager.Set(labelsJSON, name, volumeJSONFileName); err != nil { return nil, err } + } else { + log.L.Warnf("volume %q already exists and will be returned as-is", name) + // FIXME: we do not check if the existing volume has the same labels as requested - should we? } - entry := native.Volume{ - Name: name, - Mountpoint: dataPath, - Labels: Labels(volumeDataBytes), + // At this point, we either have an existing volume, or created a new one successfully + vol = &native.Volume{ + Name: name, } - if size { - entry.Size, err = Size(&entry) - if err != nil { - return nil, err - } - } - return &entry, nil -} -func (vs *volumeStore) List(size bool) (map[string]native.Volume, error) { - dEnts, err := os.ReadDir(vs.dir) - if err != nil { + if err = vs.manager.GroupEnsure(name, dataDirName); err != nil { return nil, err } - res := make(map[string]native.Volume, len(dEnts)) - for _, dEnt := range dEnts { - name := dEnt.Name() - vol, err := vs.Get(name, size) - if err != nil { - return res, err - } - res[name] = *vol + if vol.Mountpoint, err = vs.manager.Location(name, dataDirName); err != nil { + return nil, err } - return res, nil -} -func (vs *volumeStore) Remove(names []string) ([]string, error) { - var removed []string - fn := func() error { - for _, name := range names { - if err := identifiers.Validate(name); err != nil { - return fmt.Errorf("malformed name %s: %w", name, err) - } - dir := filepath.Join(vs.dir, name) - if err := os.RemoveAll(dir); err != nil { - return err - } - removed = append(removed, name) - } - return nil - } - err := lockutil.WithDirLock(vs.dir, fn) - return removed, err + return vol, nil } -func Labels(b []byte) *map[string]string { +// Private helpers +func labels(b []byte) *map[string]string { type volumeOpts struct { Labels *map[string]string `json:"labels,omitempty"` } @@ -207,21 +395,3 @@ func Labels(b []byte) *map[string]string { } return vo.Labels } - -func Size(volume *native.Volume) (int64, error) { - var size int64 - var walkFn = func(_ string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - size += info.Size() - } - return err - } - var err = filepath.Walk(volume.Mountpoint, walkFn) - if err != nil { - return 0, err - } - return size, nil -} diff --git a/pkg/namestore/namestore.go b/pkg/namestore/namestore.go index 1ab9b4b3b18..6ded12d6c95 100644 --- a/pkg/namestore/namestore.go +++ b/pkg/namestore/namestore.go @@ -14,104 +14,167 @@ limitations under the License. */ +// Package namestore provides a simple store for containers to exclusively acquire and release names. +// All methods are safe to use concurrently. +// Note that locking of the store is done at the namespace level. +// The namestore is currently used by container create, remove, rename, and as part of the ocihook events cycle. package namestore import ( + "errors" "fmt" - "os" "path/filepath" - "strings" - "github.com/containerd/containerd/identifiers" - "github.com/containerd/nerdctl/pkg/lockutil" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/identifiers" + "github.com/containerd/nerdctl/v2/pkg/store" ) -func New(dataStore, ns string) (NameStore, error) { - dir := filepath.Join(dataStore, "names", ns) - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err +// ErrNameStore will wrap all errors here +var ErrNameStore = errors.New("name-store error") + +// New will return a NameStore for a given namespace. +func New(stateDir, namespace string) (NameStore, error) { + if namespace == "" { + return nil, errors.Join(ErrNameStore, store.ErrInvalidArgument) } - store := &nameStore{ - dir: dir, + + st, err := store.New(filepath.Join(stateDir, namespace), 0, 0) + if err != nil { + return nil, errors.Join(ErrNameStore, err) } - return store, nil + + return &nameStore{ + safeStore: st, + }, nil } +// NameStore allows acquiring, releasing and renaming. +// "names" must abide by identifiers.ValidateDockerCompat +// A container cannot release or rename a name it does not own. +// A container cannot acquire a name that is already owned by another container. +// Re-acquiring a name does not error and is a no-op. +// Double releasing a name will error. +// Note that technically a given container may acquire multiple different names, although this is not +// something we do in the codebase. type NameStore interface { + // Acquire exclusively grants `name` to container with `id`. Acquire(name, id string) error + // Acquire allows the container owning a specific name to release it Release(name, id string) error + // Rename allows the container owning a specific name to change it to newName (if available) Rename(oldName, id, newName string) error } type nameStore struct { - dir string + safeStore store.Store } -func (x *nameStore) Acquire(name, id string) error { - if err := identifiers.Validate(name); err != nil { - return fmt.Errorf("invalid name %q: %w", name, err) - } - if strings.TrimSpace(id) != id { - return fmt.Errorf("untrimmed ID %q", id) - } - fn := func() error { - fileName := filepath.Join(x.dir, name) - if b, err := os.ReadFile(fileName); err == nil { - return fmt.Errorf("name %q is already used by ID %q", name, string(b)) +func (x *nameStore) Acquire(name, id string) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNameStore, err) } - return os.WriteFile(fileName, []byte(id), 0600) + }() + + if err = identifiers.ValidateDockerCompat(name); err != nil { + return err } - return lockutil.WithDirLock(x.dir, fn) + + return x.safeStore.WithLock(func() error { + var previousID []byte + previousID, err = x.safeStore.Get(name) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + return err + } + } else if string(previousID) == "" { + // This has happened in the past, probably following some other error condition of OS restart + // We do warn about it, but do not hard-error and let the new container acquire the name + log.L.Warnf("name %q was locked by an empty id - this is abnormal and should be reported", name) + } else if string(previousID) != id { + // If the name is already used by another container, that is a hard error + return fmt.Errorf("name %q is already used by ID %q", name, previousID) + } + + // If the id was the same, we are "re-acquiring". + // Maybe containerd was bounced, so previously running containers that would get restarted will go again through + // onCreateRuntime (unlike in a "normal" stop/start flow), without ever had gone through onPostStop. + // As such, reacquiring by the same id is not a bug... + // See: https://github.com/containerd/nerdctl/issues/3354 + return x.safeStore.Set([]byte(id), name) + }) } -func (x *nameStore) Release(name, id string) error { - if name == "" { - return nil - } - if err := identifiers.Validate(name); err != nil { - return fmt.Errorf("invalid name %q: %w", name, err) - } - if strings.TrimSpace(id) != id { - return fmt.Errorf("untrimmed ID %q", id) +func (x *nameStore) Release(name, id string) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNameStore, err) + } + }() + + if err = identifiers.ValidateDockerCompat(name); err != nil { + return err } - fn := func() error { - fileName := filepath.Join(x.dir, name) - b, err := os.ReadFile(fileName) + + return x.safeStore.WithLock(func() error { + var content []byte + content, err = x.safeStore.Get(name) if err != nil { - if os.IsNotExist(err) { - err = nil - } return err } - if s := strings.TrimSpace(string(b)); s != id { - return fmt.Errorf("name %q is used by ID %q, not by %q", name, s, id) + + if string(content) != id { + // Never seen this, but technically possible if downstream code is messed-up + return fmt.Errorf("cannot release name %q (used by ID %q, not by %q)", name, content, id) } - return os.RemoveAll(fileName) - } - return lockutil.WithDirLock(x.dir, fn) + + return x.safeStore.Delete(name) + }) } -func (x *nameStore) Rename(oldName, id, newName string) error { - if oldName == "" || newName == "" { - return nil - } - if err := identifiers.Validate(newName); err != nil { - return fmt.Errorf("invalid name %q: %w", oldName, err) +func (x *nameStore) Rename(oldName, id, newName string) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrNameStore, err) + } + }() + + if err = identifiers.ValidateDockerCompat(newName); err != nil { + return err } - fn := func() error { - oldFileName := filepath.Join(x.dir, oldName) - b, err := os.ReadFile(oldFileName) + + return x.safeStore.WithLock(func() error { + var doesExist bool + var content []byte + doesExist, err = x.safeStore.Exists(newName) + if err != nil { + return err + } + + if doesExist { + content, err = x.safeStore.Get(newName) + if err != nil { + return err + } + return fmt.Errorf("name %q is already used by ID %q", newName, string(content)) + } + + content, err = x.safeStore.Get(oldName) if err != nil { return err } - if s := strings.TrimSpace(string(b)); s != id { - return fmt.Errorf("name %q is used by ID %q, not by %q", oldName, s, id) + + if string(content) != id { + return fmt.Errorf("name %q is used by ID %q, not by %q", oldName, content, id) } - newFileName := filepath.Join(x.dir, newName) - if b, err := os.ReadFile(newFileName); err == nil { - return fmt.Errorf("name %q is already used by ID %q", newName, string(b)) + + err = x.safeStore.Set(content, newName) + if err != nil { + return err } - return os.Rename(oldFileName, newFileName) - } - return lockutil.WithDirLock(x.dir, fn) + + return x.safeStore.Delete(oldName) + }) } diff --git a/pkg/netutil/cni_plugin.go b/pkg/netutil/cni_plugin.go index b333d5c15e4..f4d65359f2e 100644 --- a/pkg/netutil/cni_plugin.go +++ b/pkg/netutil/cni_plugin.go @@ -33,17 +33,3 @@ type IPAMRoute struct { GW string `json:"gw,omitempty"` Gateway string `json:"gateway,omitempty"` } - -type isolationConfig struct { - PluginType string `json:"type"` -} - -func newIsolationPlugin() *isolationConfig { - return &isolationConfig{ - PluginType: "isolation", - } -} - -func (*isolationConfig) GetPluginType() string { - return "isolation" -} diff --git a/pkg/netutil/cni_plugin_unix.go b/pkg/netutil/cni_plugin_unix.go index 535a003847e..8d863d3be93 100644 --- a/pkg/netutil/cni_plugin_unix.go +++ b/pkg/netutil/cni_plugin_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -18,6 +18,8 @@ package netutil +import "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + // bridgeConfig describes the bridge plugin type bridgeConfig struct { PluginType string `json:"type"` @@ -31,12 +33,14 @@ type bridgeConfig struct { PromiscMode bool `json:"promiscMode,omitempty"` Vlan int `json:"vlan,omitempty"` IPAM map[string]interface{} `json:"ipam"` + Capabilities map[string]bool `json:"capabilities,omitempty"` } func newBridgePlugin(bridgeName string) *bridgeConfig { return &bridgeConfig{ - PluginType: "bridge", - BrName: bridgeName, + PluginType: "bridge", + BrName: bridgeName, + Capabilities: map[string]bool{}, } } @@ -46,16 +50,18 @@ func (*bridgeConfig) GetPluginType() string { // vlanConfig describes the macvlan/ipvlan config type vlanConfig struct { - PluginType string `json:"type"` - Master string `json:"master"` - Mode string `json:"mode,omitempty"` - MTU int `json:"mtu,omitempty"` - IPAM map[string]interface{} `json:"ipam"` + PluginType string `json:"type"` + Master string `json:"master"` + Mode string `json:"mode,omitempty"` + MTU int `json:"mtu,omitempty"` + IPAM map[string]interface{} `json:"ipam"` + Capabilities map[string]bool `json:"capabilities,omitempty"` } func newVLANPlugin(pluginType string) *vlanConfig { return &vlanConfig{ - PluginType: pluginType, + PluginType: pluginType, + Capabilities: map[string]bool{}, } } @@ -93,10 +99,15 @@ type firewallConfig struct { } func newFirewallPlugin() *firewallConfig { - return &firewallConfig{ + c := &firewallConfig{ PluginType: "firewall", IngressPolicy: "same-bridge", } + if rootlessutil.IsRootless() { + // https://github.com/containerd/nerdctl/issues/2818 + c.Backend = "iptables" + } + return c } func (*firewallConfig) GetPluginType() string { @@ -133,10 +144,25 @@ func newHostLocalIPAMConfig() *hostLocalIPAMConfig { } } -// https://github.com/containernetworking/plugins/blob/v1.1.0/plugins/ipam/dhcp/main.go#L43-L54 +// https://github.com/containernetworking/plugins/blob/v1.4.1/plugins/ipam/dhcp/main.go#L43-L54 type dhcpIPAMConfig struct { - Type string `json:"type"` - DaemonSocketPath string `json:"daemonSocketPath,omitempty"` + Type string `json:"type"` + DaemonSocketPath string `json:"daemonSocketPath"` + ProvideOptions []provideOption `json:"provide,omitempty"` + RequestOptions []requestOption `json:"request,omitempty"` +} + +type provideOption struct { + Option string `json:"option"` + + Value string `json:"value"` + ValueFromCNIArg string `json:"fromArg"` +} + +type requestOption struct { + SkipDefault bool `json:"skipDefault"` + + Option string `json:"option"` } func newDHCPIPAMConfig() *dhcpIPAMConfig { diff --git a/pkg/netutil/nettype/nettype.go b/pkg/netutil/nettype/nettype.go index 721167037c7..d319254260d 100644 --- a/pkg/netutil/nettype/nettype.go +++ b/pkg/netutil/nettype/nettype.go @@ -29,6 +29,7 @@ const ( Host CNI Container + Namespace ) var netTypeToName = map[interface{}]string{ @@ -37,6 +38,7 @@ var netTypeToName = map[interface{}]string{ Host: "host", CNI: "cni", Container: "container", + Namespace: "ns", } func Detect(names []string) (Type, error) { @@ -54,6 +56,8 @@ func Detect(names []string) (Type, error) { tmp = Host case "container": tmp = Container + case "ns": + tmp = Namespace default: tmp = CNI } diff --git a/pkg/netutil/netutil.go b/pkg/netutil/netutil.go index 6b0243eaf4d..5708cbc9487 100644 --- a/pkg/netutil/netutil.go +++ b/pkg/netutil/netutil.go @@ -30,25 +30,71 @@ import ( "strconv" "strings" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/containerd/containerd/namespaces" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/lockutil" - "github.com/containerd/nerdctl/pkg/netutil/nettype" - subnetutil "github.com/containerd/nerdctl/pkg/netutil/subnet" - "github.com/containerd/nerdctl/pkg/strutil" "github.com/containernetworking/cni/libcni" - "github.com/sirupsen/logrus" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "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" ) type CNIEnv struct { Path string NetconfPath string + Namespace string } 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 + err = lockutil.WithDirLock(e.NetconfPath, func() error { + networkConfigs, err = e.networkConfigList() + return err + }) + if err != nil { + return nil, []error{err} + } + + list = make(map[string][]*NetworkConfig) + for _, req := range reqs { + if !allowPseudoNetwork && (req == "host" || req == "none") { + errs = append(errs, fmt.Errorf("pseudo network not allowed: %s", req)) + continue + } + + result := []*NetworkConfig{} + // First match by name + for _, networkConfig := range networkConfigs { + if networkConfig.Name == req { + result = append(result, networkConfig) + } + } + // If nothing, try to match the id + if len(result) == 0 { + for _, networkConfig := range networkConfigs { + if networkConfig.NerdctlID != nil { + if len(req) <= len((*networkConfig.NerdctlID)) && (*networkConfig.NerdctlID)[0:len(req)] == req { + result = append(result, networkConfig) + } + } + } + } + list[req] = result + } + + return list, errs +} + func UsedNetworks(ctx context.Context, client *containerd.Client) (map[string][]string, error) { nsService := client.NamespaceService() nsList, err := nsService.List(ctx) @@ -86,12 +132,17 @@ func namespaceUsedNetworks(ctx context.Context, containers []containerd.Containe task, err := c.Task(ctx, nil) if err != nil { if errdefs.IsNotFound(err) { + log.G(ctx).Debugf("task not found - likely container %q was removed", c.ID()) continue } return nil, err } status, err := task.Status(ctx) if err != nil { + if errdefs.IsNotFound(err) { + log.G(ctx).Debugf("task not found - likely container %q was removed", c.ID()) + continue + } return nil, err } switch status.Status { @@ -101,6 +152,10 @@ func namespaceUsedNetworks(ctx context.Context, containers []containerd.Containe } l, err := c.Labels(ctx) if err != nil { + if errdefs.IsNotFound(err) { + log.G(ctx).Debugf("container %q is gone", c.ID()) + continue + } return nil, err } networkJSON, ok := l[labels.Networks] @@ -125,11 +180,18 @@ func namespaceUsedNetworks(ctx context.Context, containers []containerd.Containe return used, nil } -func WithDefaultNetwork() CNIEnvOpt { +func WithDefaultNetwork(bridgeIP string) CNIEnvOpt { + return func(e *CNIEnv) error { + return e.ensureDefaultNetworkConfig(bridgeIP) + } +} + +func WithNamespace(namespace string) CNIEnvOpt { return func(e *CNIEnv) error { - if err := e.ensureDefaultNetworkConfig(); err != nil { + if err := os.MkdirAll(filepath.Join(e.NetconfPath, namespace), 0755); err != nil { return err } + e.Namespace = namespace return nil } } @@ -153,7 +215,15 @@ func NewCNIEnv(cniPath, cniConfPath string, opts ...CNIEnvOpt) (*CNIEnv, error) } func (e *CNIEnv) NetworkList() ([]*NetworkConfig, error) { - return e.networkConfigList() + var netConfigList []*NetworkConfig + var err error + fn := func() error { + netConfigList, err = e.networkConfigList() + return err + } + err = lockutil.WithDirLock(e.NetconfPath, fn) + + return netConfigList, err } func (e *CNIEnv) NetworkMap() (map[string]*NetworkConfig, error) { //nolint:revive @@ -165,14 +235,32 @@ func (e *CNIEnv) NetworkMap() (map[string]*NetworkConfig, error) { //nolint:revi m := make(map[string]*NetworkConfig, len(networks)) for _, n := range networks { if original, exists := m[n.Name]; exists { - logrus.Warnf("duplicate network name %q, %#v will get superseded by %#v", n.Name, original, n) + log.L.Warnf("duplicate network name %q, %#v will get superseded by %#v", n.Name, original, n) } m[n.Name] = n } return m, nil } -func (e *CNIEnv) FilterNetworks(filterf func(*NetworkConfig) bool) ([]*NetworkConfig, error) { +func (e *CNIEnv) NetworkByNameOrID(key string) (*NetworkConfig, error) { + networks, err := e.networkConfigList() + if err != nil { + return nil, err + } + + for _, n := range networks { + if n.Name == key { + return n, nil + } + if n.NerdctlID != nil && (*n.NerdctlID == key || (*n.NerdctlID)[0:12] == key) { + return n, nil + } + } + + return nil, fmt.Errorf("no such network: %q", key) +} + +func (e *CNIEnv) filterNetworks(filterf func(*NetworkConfig) bool) ([]*NetworkConfig, error) { networkConfigs, err := e.networkConfigList() if err != nil { return nil, err @@ -187,7 +275,10 @@ func (e *CNIEnv) FilterNetworks(filterf func(*NetworkConfig) bool) ([]*NetworkCo } func (e *CNIEnv) getConfigPathForNetworkName(netName string) string { - return filepath.Join(e.NetconfPath, "nerdctl-"+netName+".conflist") + 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) { @@ -199,8 +290,8 @@ func (e *CNIEnv) usedSubnets() ([]*net.IPNet, error) { if err != nil { return nil, err } - for _, net := range networkConfigs { - usedSubnets = append(usedSubnets, net.subnets()...) + for _, netConf := range networkConfigs { + usedSubnets = append(usedSubnets, netConf.subnets()...) } return usedSubnets, nil } @@ -220,52 +311,37 @@ type cniNetworkConfig struct { Plugins []CNIPlugin `json:"plugins"` } -type CreateOptions struct { - Name string - Driver string - Options map[string]string - IPAMDriver string - IPAMOptions map[string]string - Subnet string - Gateway string - IPRange string - Labels []string -} - -func (e *CNIEnv) CreateNetwork(opts CreateOptions) (*NetworkConfig, error) { //nolint:revive - var net *NetworkConfig - netMap, err := e.NetworkMap() - if err != nil { - return nil, err - } - - if _, ok := netMap[opts.Name]; ok { - return nil, errdefs.ErrAlreadyExists - } +func (e *CNIEnv) CreateNetwork(opts types.NetworkCreateOptions) (*NetworkConfig, error) { //nolint:revive + var netConf *NetworkConfig fn := func() error { - ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnet, opts.Gateway, opts.IPRange, opts.IPAMOptions) + netMap, err := e.NetworkMap() if err != nil { return err } - plugins, err := e.generateCNIPlugins(opts.Driver, opts.Name, ipam, opts.Options) + + 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 } - net, err = e.generateNetworkConfig(opts.Name, opts.Labels, plugins) + plugins, err := e.generateCNIPlugins(opts.Driver, opts.Name, ipam, opts.Options, opts.IPv6) if err != nil { return err } - if err := e.writeNetworkConfig(net); err != nil { + netConf, err = e.generateNetworkConfig(opts.Name, opts.Labels, plugins) + if err != nil { return err } - return nil + return e.writeNetworkConfig(netConf) } - err = lockutil.WithDirLock(e.NetconfPath, fn) + err := lockutil.WithDirLock(e.NetconfPath, fn) if err != nil { return nil, err } - return net, nil + return netConf, nil } func (e *CNIEnv) RemoveNetwork(net *NetworkConfig) error { @@ -273,10 +349,7 @@ func (e *CNIEnv) RemoveNetwork(net *NetworkConfig) error { if err := os.RemoveAll(net.File); err != nil { return err } - if err := net.clean(); err != nil { - return err - } - return nil + return net.clean() } return lockutil.WithDirLock(e.NetconfPath, fn) } @@ -295,13 +368,13 @@ func (e *CNIEnv) GetDefaultNetworkConfig() (*NetworkConfig, error) { } return false } - labelMatches, err := e.FilterNetworks(defaultLabelFilterF) + labelMatches, err := e.filterNetworks(defaultLabelFilterF) if err != nil { return nil, err } if len(labelMatches) >= 1 { if len(labelMatches) > 1 { - logrus.Warnf("returning the first network bearing the %q label out of the multiple found: %#v", labels.NerdctlDefaultNetwork, labelMatches) + log.L.Warnf("returning the first network bearing the %q label out of the multiple found: %#v", labels.NerdctlDefaultNetwork, labelMatches) } return labelMatches[0], nil } @@ -310,20 +383,20 @@ func (e *CNIEnv) GetDefaultNetworkConfig() (*NetworkConfig, error) { defaultNameFilterF := func(nc *NetworkConfig) bool { return nc.Name == DefaultNetworkName } - nameMatches, err := e.FilterNetworks(defaultNameFilterF) + nameMatches, err := e.filterNetworks(defaultNameFilterF) if err != nil { return nil, err } if len(nameMatches) >= 1 { if len(nameMatches) > 1 { - logrus.Warnf("returning the first network bearing the %q default network name out of the multiple found: %#v", DefaultNetworkName, nameMatches) + log.L.Warnf("returning the first network bearing the %q default network name out of the multiple found: %#v", DefaultNetworkName, nameMatches) } // 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 { - logrus.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) + 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) } return nameMatches[0], nil @@ -332,31 +405,44 @@ func (e *CNIEnv) GetDefaultNetworkConfig() (*NetworkConfig, error) { return nil, nil } -func (e *CNIEnv) ensureDefaultNetworkConfig() error { +func (e *CNIEnv) ensureDefaultNetworkConfig(bridgeIP string) error { defaultNet, err := e.GetDefaultNetworkConfig() if err != nil { return fmt.Errorf("failed to check for default network: %s", err) } if defaultNet == nil { - if err := e.createDefaultNetworkConfig(); err != nil { + if err := e.createDefaultNetworkConfig(bridgeIP); err != nil { return fmt.Errorf("failed to create default network: %s", err) } } return nil } -func (e *CNIEnv) createDefaultNetworkConfig() 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) } - opts := CreateOptions{ + + bridgeCIDR := DefaultCIDR + bridgeGatewayIP := "" + if bridgeIP != "" { + bIP, bCIDR, err := net.ParseCIDR(bridgeIP) + if err != nil { + return fmt.Errorf("invalid bridge ip %s: %s", bridgeIP, err) + } + bridgeGatewayIP = bIP.String() + bridgeCIDR = bCIDR.String() + } + opts := types.NetworkCreateOptions{ Name: DefaultNetworkName, Driver: DefaultNetworkName, - Subnet: DefaultCIDR, + Subnets: []string{bridgeCIDR}, + Gateway: bridgeGatewayIP, IPAMDriver: "default", Labels: []string{fmt.Sprintf("%s=true", labels.NerdctlDefaultNetwork)}, } + _, err := e.CreateNetwork(opts) if err != nil && !errdefs.IsAlreadyExists(err) { return err @@ -410,46 +496,69 @@ func (e *CNIEnv) writeNetworkConfig(net *NetworkConfig) error { if _, err := os.Stat(filename); err == nil { return errdefs.ErrAlreadyExists } - if err := os.WriteFile(filename, net.Bytes, 0644); err != nil { - return err - } - return nil + return os.WriteFile(filename, net.Bytes, 0644) } // networkConfigList loads config from dir if dir exists. func (e *CNIEnv) networkConfigList() ([]*NetworkConfig, error) { - l := []*NetworkConfig{} - fileNames, err := libcni.ConfFiles(e.NetconfPath, []string{".conf", ".conflist", ".json"}) + 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) +} + +func cniLoad(fileNames []string) (configList []*NetworkConfig, err error) { + var fileName string + sort.Strings(fileNames) - for _, fileName := range fileNames { - var lcl *libcni.NetworkConfigList + + for _, fileName = range fileNames { + var bytes []byte + bytes, err = os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("error reading %s: %w", fileName, err) + } + + var netConfigList *libcni.NetworkConfigList if strings.HasSuffix(fileName, ".conflist") { - lcl, err = libcni.ConfListFromFile(fileName) + netConfigList, err = libcni.ConfListFromBytes(bytes) if err != nil { - return nil, err + return nil, wrapCNIError(fileName, err) } } else { - lc, err := libcni.ConfFromFile(fileName) + var netConfig *libcni.NetworkConfig + netConfig, err = libcni.ConfFromBytes(bytes) if err != nil { - return nil, err + return nil, wrapCNIError(fileName, err) } - lcl, err = libcni.ConfListFromConf(lc) + netConfigList, err = libcni.ConfListFromConf(netConfig) if err != nil { - return nil, err + return nil, wrapCNIError(fileName, err) } } - id, labels := nerdctlIDLabels(lcl.Bytes) - l = append(l, &NetworkConfig{ - NetworkConfigList: lcl, + id, nerdctlLabels := nerdctlIDLabels(netConfigList.Bytes) + configList = append(configList, &NetworkConfig{ + NetworkConfigList: netConfigList, NerdctlID: id, - NerdctlLabels: labels, + NerdctlLabels: nerdctlLabels, File: fileName, }) } - return l, nil + + return configList, nil } func nerdctlIDLabels(b []byte) (*string, *map[string]string) { @@ -475,7 +584,7 @@ func (e *CNIEnv) parseSubnet(subnetStr string) (*net.IPNet, error) { return nil, err } if subnetStr == "" { - _, defaultSubnet, _ := net.ParseCIDR(DefaultCIDR) + _, defaultSubnet, _ := net.ParseCIDR(StartingCIDR) subnet, err := subnetutil.GetFreeSubnet(defaultSubnet, usedSubnets) if err != nil { return nil, err @@ -548,7 +657,8 @@ func structToMap(in interface{}) (map[string]interface{}, error) { } // ParseMTU parses the mtu option -func ParseMTU(mtu string) (int, error) { +// nolint:unused +func parseMTU(mtu string) (int, error) { if mtu == "" { return 0, nil // default } diff --git a/pkg/netutil/netutil_linux_test.go b/pkg/netutil/netutil_linux_test.go index d3c090072a4..b547c5dfcc9 100644 --- a/pkg/netutil/netutil_linux_test.go +++ b/pkg/netutil/netutil_linux_test.go @@ -1,5 +1,3 @@ -//go:build linux - /* Copyright The containerd Authors. @@ -21,7 +19,7 @@ package netutil import ( "testing" - "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) // Tests whether nerdctl properly creates the default network when required. @@ -32,4 +30,5 @@ func TestDefaultNetworkCreation(t *testing.T) { } testDefaultNetworkCreation(t) + testDefaultNetworkCreationWithBridgeIP(t) } diff --git a/pkg/netutil/netutil_test.go b/pkg/netutil/netutil_test.go index c102f281999..a17f4cb6197 100644 --- a/pkg/netutil/netutil_test.go +++ b/pkg/netutil/netutil_test.go @@ -18,6 +18,7 @@ package netutil import ( "bytes" + "encoding/json" "fmt" "net" "os" @@ -26,13 +27,15 @@ import ( "testing" "text/template" - ncdefaults "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/testutil" - "gotest.tools/v3/assert" + + ncdefaults "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/testutil" ) +const testBridgeIP = "10.1.100.1/24" // nolint:unused + const preExistingNetworkConfigTemplate = ` { "cniVersion": "0.2.0", @@ -133,6 +136,7 @@ func TestParseIPAMRange(t *testing.T) { // Note that this test will require a CNI driver bearing the same name as // the type of the default network. (denoted by netutil.DefaultNetworkName, // which is used as both the name of the default network and its Driver) +// nolint:unused func testDefaultNetworkCreation(t *testing.T) { // To prevent subnet collisions when attempting to recreate the default network // in the isolated CNI config dir we'll be using, we must first delete @@ -159,10 +163,10 @@ func testDefaultNetworkCreation(t *testing.T) { assert.Assert(t, defaultNetConf == nil) // Attempt to create the default network. - err = cniEnv.ensureDefaultNetworkConfig() + err = cniEnv.ensureDefaultNetworkConfig("") assert.NilError(t, err) - // Ensure no default network config is present now. + // Ensure default network config is present now. defaultNetConf, err = cniEnv.GetDefaultNetworkConfig() assert.NilError(t, err) assert.Assert(t, defaultNetConf != nil) @@ -181,7 +185,106 @@ func testDefaultNetworkCreation(t *testing.T) { assert.Assert(t, boolv) // Ensure network isn't created twice or accidentally re-created. - err = cniEnv.ensureDefaultNetworkConfig() + err = cniEnv.ensureDefaultNetworkConfig("") + assert.NilError(t, err) + + // Check for any other network config files. + files := []os.FileInfo{} + walkF := func(p string, info os.FileInfo, err error) error { + files = append(files, info) + return nil + } + err = filepath.Walk(cniConfTestDir, walkF) + assert.NilError(t, err) + assert.Assert(t, len(files) == 2) // files[0] is the entry for '.' + assert.Assert(t, filepath.Join(cniConfTestDir, files[1].Name()) == defaultNetConf.File) + assert.Assert(t, firstConfigModTime == files[1].ModTime()) +} + +// Tests whether nerdctl properly creates the default network +// with a custom bridge IP and subnet. +// nolint:unused +func testDefaultNetworkCreationWithBridgeIP(t *testing.T) { + // To prevent subnet collisions when attempting to recreate the default network + // in the isolated CNI config dir we'll be using, we must first delete + // the network in the default CNI config dir. + defaultCniEnv := CNIEnv{ + Path: ncdefaults.CNIPath(), + NetconfPath: ncdefaults.CNINetConfPath(), + } + defaultNet, err := defaultCniEnv.GetDefaultNetworkConfig() + assert.NilError(t, err) + if defaultNet != nil { + assert.NilError(t, defaultCniEnv.RemoveNetwork(defaultNet)) + } + + // We create a tempdir for the CNI conf path to ensure an empty env for this test. + cniConfTestDir := t.TempDir() + cniEnv := CNIEnv{ + Path: ncdefaults.CNIPath(), + NetconfPath: cniConfTestDir, + } + // Ensure no default network config is not present. + defaultNetConf, err := cniEnv.GetDefaultNetworkConfig() + assert.NilError(t, err) + assert.Assert(t, defaultNetConf == nil) + + // Attempt to create the default network with a test bridgeIP + err = cniEnv.ensureDefaultNetworkConfig(testBridgeIP) + assert.NilError(t, err) + + // Ensure default network config is present now. + defaultNetConf, err = cniEnv.GetDefaultNetworkConfig() + assert.NilError(t, err) + assert.Assert(t, defaultNetConf != nil) + + // Check network config file present. + stat, err := os.Stat(defaultNetConf.File) + assert.NilError(t, err) + firstConfigModTime := stat.ModTime() + + // Check default network label present. + assert.Assert(t, defaultNetConf.NerdctlLabels != nil) + lstr, ok := (*defaultNetConf.NerdctlLabels)[labels.NerdctlDefaultNetwork] + assert.Assert(t, ok) + boolv, err := strconv.ParseBool(lstr) + assert.NilError(t, err) + assert.Assert(t, boolv) + + // Check bridge IP is set. + assert.Assert(t, defaultNetConf.Plugins != nil) + assert.Assert(t, len(defaultNetConf.Plugins) > 0) + bridgePlugin := defaultNetConf.Plugins[0] + var bridgeConfig struct { + Type string `json:"type"` + Bridge string `json:"bridge"` + IPAM struct { + Ranges [][]struct { + Gateway string `json:"gateway"` + Subnet string `json:"subnet"` + } `json:"ranges"` + Routes []struct { + Dst string `json:"dst"` + } `json:"routes"` + Type string `json:"type"` + } `json:"ipam"` + } + + err = json.Unmarshal(bridgePlugin.Bytes, &bridgeConfig) + if err != nil { + t.Fatalf("Failed to parse bridge plugin config: %v", err) + } + + // Assert on bridge plugin configuration + assert.Equal(t, "bridge", bridgeConfig.Type) + // Assert on IPAM configuration + assert.Equal(t, "10.1.100.1", bridgeConfig.IPAM.Ranges[0][0].Gateway) + assert.Equal(t, "10.1.100.0/24", bridgeConfig.IPAM.Ranges[0][0].Subnet) + assert.Equal(t, "0.0.0.0/0", bridgeConfig.IPAM.Routes[0].Dst) + assert.Equal(t, "host-local", bridgeConfig.IPAM.Type) + + // Ensure network isn't created twice or accidentally re-created. + err = cniEnv.ensureDefaultNetworkConfig(testBridgeIP) assert.NilError(t, err) // Check for any other network config files. @@ -248,7 +351,7 @@ func TestNetworkWithDefaultNameAlreadyExists(t *testing.T) { assert.Assert(t, defaultNetConf != nil) assert.Assert(t, defaultNetConf.File == testConfFile) - err = cniEnv.ensureDefaultNetworkConfig() + err = cniEnv.ensureDefaultNetworkConfig("") assert.NilError(t, err) netConfs, err = cniEnv.NetworkList() diff --git a/pkg/netutil/netutil_unix.go b/pkg/netutil/netutil_unix.go index 2a221ca8bcf..eba2a5e3b8b 100644 --- a/pkg/netutil/netutil_unix.go +++ b/pkg/netutil/netutil_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. @@ -25,21 +25,30 @@ import ( "net" "os/exec" "path/filepath" + "strconv" "strings" "github.com/Masterminds/semver/v3" - "github.com/containerd/nerdctl/pkg/defaults" - "github.com/containerd/nerdctl/pkg/strutil" - "github.com/containerd/nerdctl/pkg/systemutil" - "github.com/mitchellh/mapstructure" - "github.com/sirupsen/logrus" + "github.com/go-viper/mapstructure/v2" "github.com/vishvananda/netlink" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/defaults" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" + "github.com/containerd/nerdctl/v2/pkg/systemutil" ) const ( DefaultNetworkName = "bridge" DefaultCIDR = "10.4.0.0/24" DefaultIPAMDriver = "host-local" + + // 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 + StartingCIDR = "10.4.1.0/24" ) func (n *NetworkConfig) subnets() []*net.IPNet { @@ -81,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) ([]CNIPlugin, error) { +func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool) ([]CNIPlugin, error) { var ( plugins []CNIPlugin err error @@ -89,10 +98,16 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] switch driver { case "bridge": mtu := 0 + iPMasq := true for opt, v := range opts { switch opt { case "mtu", "com.docker.network.driver.mtu": - mtu, err = ParseMTU(v) + mtu, err = parseMTU(v) + if err != nil { + return nil, err + } + case "ip-masq", "com.docker.network.bridge.enable_ip_masquerade": + iPMasq, err = strconv.ParseBool(v) if err != nil { return nil, err } @@ -109,10 +124,23 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] bridge.MTU = mtu bridge.IPAM = ipam bridge.IsGW = true - bridge.IPMasq = true + bridge.IPMasq = iPMasq bridge.HairpinMode = true + if ipv6 { + bridge.Capabilities["ips"] = true + } plugins = []CNIPlugin{bridge, newPortMapPlugin(), newFirewallPlugin(), newTuningPlugin()} - plugins = fixUpIsolation(e, name, plugins) + if name != DefaultNetworkName { + firewallPath := filepath.Join(e.Path, "firewall") + ok, err := firewallPluginGEQ110(firewallPath) + if err != nil { + log.L.WithError(err).Warnf("Failed to detect whether %q is newer than v1.1.0", firewallPath) + } + if !ok { + log.L.Warnf("To isolate bridge networks, CNI plugin \"firewall\" (>= 1.1.0) needs to be installed in CNI_PATH (%q), see https://github.com/containernetworking/plugins", + e.Path) + } + } case "macvlan", "ipvlan": mtu := 0 mode := "" @@ -120,7 +148,7 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] for opt, v := range opts { switch opt { case "mtu", "com.docker.network.driver.mtu": - mtu, err = ParseMTU(v) + mtu, err = parseMTU(v) if err != nil { return nil, err } @@ -148,6 +176,9 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] vlan.Master = master vlan.Mode = mode vlan.IPAM = ipam + if ipv6 { + vlan.Capabilities["ips"] = true + } plugins = []CNIPlugin{vlan} default: return nil, fmt.Errorf("unsupported cni driver %q", driver) @@ -155,32 +186,61 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnetStr, gatewayStr, ipRangeStr string, opts map[string]string) (map[string]interface{}, error) { +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool) (map[string]interface{}, error) { var ipamConfig interface{} switch driver { case "default", "host-local": - subnet, err := e.parseSubnet(subnetStr) - if err != nil { - return nil, err + ipamConf := newHostLocalIPAMConfig() + ipamConf.Routes = []IPAMRoute{ + {Dst: "0.0.0.0/0"}, } - ipamRange, err := parseIPAMRange(subnet, gatewayStr, ipRangeStr) + ranges, findIPv4, err := e.parseIPAMRanges(subnets, gatewayStr, ipRangeStr, ipv6) if err != nil { return nil, err } - - ipamConf := newHostLocalIPAMConfig() - ipamConf.Routes = []IPAMRoute{ - {Dst: "0.0.0.0/0"}, + ipamConf.Ranges = append(ipamConf.Ranges, ranges...) + if !findIPv4 { + ranges, _, _ = e.parseIPAMRanges([]string{""}, gatewayStr, ipRangeStr, ipv6) + ipamConf.Ranges = append(ipamConf.Ranges, ranges...) } - ipamConf.Ranges = append(ipamConf.Ranges, []IPAMRange{*ipamRange}) ipamConfig = ipamConf case "dhcp": ipamConf := newDHCPIPAMConfig() ipamConf.DaemonSocketPath = filepath.Join(defaults.CNIRuntimeDir(), "dhcp.sock") - // TODO: support IPAM options for dhcp if err := systemutil.IsSocketAccessible(ipamConf.DaemonSocketPath); err != nil { - logrus.Warnf("cannot access dhcp socket %q (hint: try running with `dhcp daemon --socketpath=%s &` in CNI_PATH to launch the dhcp daemon)", ipamConf.DaemonSocketPath, ipamConf.DaemonSocketPath) + log.L.Warnf("cannot access dhcp socket %q (hint: try running with `dhcp daemon --socketpath=%s &` in CNI_PATH to launch the dhcp daemon)", ipamConf.DaemonSocketPath, ipamConf.DaemonSocketPath) + } + + // Set the host-name option to the value of passed argument NERDCTL_CNI_DHCP_HOSTNAME + opts["host-name"] = `{"type": "provide", "fromArg": "NERDCTL_CNI_DHCP_HOSTNAME"}` + + // Convert all user-defined ipam-options into serializable options + for optName, optValue := range opts { + parsed := &struct { + Type string `json:"type"` + Value string `json:"value"` + ValueFromCNIArg string `json:"fromArg"` + SkipDefault bool `json:"skipDefault"` + }{} + if err := json.Unmarshal([]byte(optValue), parsed); err != nil { + return nil, fmt.Errorf("unparsable ipam option %s %q", optName, optValue) + } + if parsed.Type == "provide" { + ipamConf.ProvideOptions = append(ipamConf.ProvideOptions, provideOption{ + Option: optName, + Value: parsed.Value, + ValueFromCNIArg: parsed.ValueFromCNIArg, + }) + } else if parsed.Type == "request" { + ipamConf.RequestOptions = append(ipamConf.RequestOptions, requestOption{ + Option: optName, + SkipDefault: parsed.SkipDefault, + }) + } else { + return nil, fmt.Errorf("ipam option must have a type (provide or request)") + } } + ipamConfig = ipamConf default: return nil, fmt.Errorf("unsupported ipam driver %q", driver) @@ -193,37 +253,28 @@ func (e *CNIEnv) generateIPAM(driver string, subnetStr, gatewayStr, ipRangeStr s return ipam, nil } -func fixUpIsolation(e *CNIEnv, name string, plugins []CNIPlugin) []CNIPlugin { - isolationPath := filepath.Join(e.Path, "isolation") - if _, err := exec.LookPath(isolationPath); err == nil { - // the warning is suppressed for DefaultNetworkName (because multi-bridge networking is not involved) - if name != DefaultNetworkName { - logrus.Warnf(`network %q: Using the deprecated CNI "isolation" plugin instead of CNI "firewall" plugin (>= 1.1.0) ingressPolicy. -To dismiss this warning, uninstall %q and install CNI "firewall" plugin (>= 1.1.0) from https://github.com/containernetworking/plugins`, - name, isolationPath) +func (e *CNIEnv) parseIPAMRanges(subnets []string, gateway, ipRange string, ipv6 bool) ([][]IPAMRange, bool, error) { + findIPv4 := false + ranges := make([][]IPAMRange, 0, len(subnets)) + for i := range subnets { + subnet, err := e.parseSubnet(subnets[i]) + if err != nil { + return nil, findIPv4, err } - plugins = append(plugins, newIsolationPlugin()) - for _, f := range plugins { - if x, ok := f.(*firewallConfig); ok { - if name != DefaultNetworkName { - logrus.Warnf("network %q: Unsetting firewall ingressPolicy %q (because using the deprecated \"isolation\" plugin)", name, x.IngressPolicy) - } - x.IngressPolicy = "" - } + // if ipv6 flag is not set, subnets of ipv6 should be excluded + if !ipv6 && subnet.IP.To4() == nil { + continue } - } else if name != DefaultNetworkName { - firewallPath := filepath.Join(e.Path, "firewall") - ok, err := firewallPluginGEQ110(firewallPath) - if err != nil { - logrus.WithError(err).Warnf("Failed to detect whether %q is newer than v1.1.0", firewallPath) + if !findIPv4 && subnet.IP.To4() != nil { + findIPv4 = true } - if !ok { - logrus.Warnf("To isolate bridge networks, CNI plugin \"firewall\" (>= 1.1.0) needs to be installed in CNI_PATH (%q), see https://github.com/containernetworking/plugins", - e.Path) + ipamRange, err := parseIPAMRange(subnet, gateway, ipRange) + if err != nil { + return nil, findIPv4, err } + ranges = append(ranges, []IPAMRange{*ipamRange}) } - - return plugins + return ranges, findIPv4, nil } func firewallPluginGEQ110(firewallPath string) (bool, error) { @@ -259,7 +310,7 @@ func firewallPluginGEQ110(firewallPath string) (bool, error) { return ver.GreaterThan(ver110) || ver.Equal(ver110), nil } -// guesssFirewallPluginVersion guess the version of the CNI firewall plugin (not the version of the implemented CNI spec). +// guessFirewallPluginVersion guess the version of the CNI firewall plugin (not the version of the implemented CNI spec). // // stderr is like "CNI firewall plugin v1.1.0\n", or "CNI firewall plugin version unknown\n" func guessFirewallPluginVersion(stderr string) (*semver.Version, error) { @@ -281,11 +332,13 @@ func guessFirewallPluginVersion(stderr string) (*semver.Version, error) { } func removeBridgeNetworkInterface(netIf string) error { - link, err := netlink.LinkByName(netIf) - if err == nil { - if err := netlink.LinkDel(link); err != nil { - return fmt.Errorf("failed to remove network interface %s: %v", netIf, err) + return rootlessutil.WithDetachedNetNSIfAny(func() error { + link, err := netlink.LinkByName(netIf) + if err == nil { + if err := netlink.LinkDel(link); err != nil { + return fmt.Errorf("failed to remove network interface %s: %v", netIf, err) + } } - } - return nil + return nil + }) } diff --git a/pkg/netutil/netutil_unix_test.go b/pkg/netutil/netutil_unix_test.go index 4d9acc7cb91..5a2d66d4451 100644 --- a/pkg/netutil/netutil_unix_test.go +++ b/pkg/netutil/netutil_unix_test.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. diff --git a/pkg/netutil/netutil_windows.go b/pkg/netutil/netutil_windows.go index 31d28140fc4..8e0e67a01ed 100644 --- a/pkg/netutil/netutil_windows.go +++ b/pkg/netutil/netutil_windows.go @@ -21,12 +21,17 @@ import ( "fmt" "net" - "github.com/mitchellh/mapstructure" + "github.com/go-viper/mapstructure/v2" ) const ( DefaultNetworkName = "nat" DefaultCIDR = "10.4.0.0/24" + + // 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ß + StartingCIDR = "10.4.1.0/24" ) func (n *NetworkConfig) subnets() []*net.IPNet { @@ -53,7 +58,7 @@ func (n *NetworkConfig) clean() error { return nil } -func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string) ([]CNIPlugin, error) { +func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]interface{}, opts map[string]string, ipv6 bool) ([]CNIPlugin, error) { var plugins []CNIPlugin switch driver { case "nat": @@ -66,8 +71,15 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] return plugins, nil } -func (e *CNIEnv) generateIPAM(driver string, subnetStr, gatewayStr, ipRangeStr string, opts map[string]string) (map[string]interface{}, error) { - subnet, err := e.parseSubnet(subnetStr) +func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRangeStr string, opts map[string]string, ipv6 bool) (map[string]interface{}, error) { + switch driver { + case "default": + default: + return nil, fmt.Errorf("unsupported ipam driver %q", driver) + } + + ipamConfig := newWindowsIPAMConfig() + subnet, err := e.parseSubnet(subnets[0]) if err != nil { return nil, err } @@ -75,25 +87,11 @@ func (e *CNIEnv) generateIPAM(driver string, subnetStr, gatewayStr, ipRangeStr s if err != nil { return nil, err } - - var ipamConfig interface{} - switch driver { - case "default": - ipamConf := newWindowsIPAMConfig() - ipamConf.Subnet = ipamRange.Subnet - ipamConf.Routes = append(ipamConf.Routes, IPAMRoute{Gateway: ipamRange.Gateway}) - ipamConfig = ipamConf - default: - return nil, fmt.Errorf("unsupported ipam driver %q", driver) - } - + ipamConfig.Subnet = ipamRange.Subnet + ipamConfig.Routes = append(ipamConfig.Routes, IPAMRoute{Gateway: ipamRange.Gateway}) ipam, err := structToMap(ipamConfig) if err != nil { return nil, err } return ipam, nil } - -func removeBridgeNetworkInterface(name string) error { - return nil -} diff --git a/pkg/netutil/netutil_windows_test.go b/pkg/netutil/netutil_windows_test.go index 7545230848f..eb26eef9449 100644 --- a/pkg/netutil/netutil_windows_test.go +++ b/pkg/netutil/netutil_windows_test.go @@ -1,5 +1,3 @@ -//go:build windows - /* Copyright The containerd Authors. diff --git a/pkg/netutil/subnet/subnet.go b/pkg/netutil/subnet/subnet.go index 188ab88e9f3..190c7dd4f81 100644 --- a/pkg/netutil/subnet/subnet.go +++ b/pkg/netutil/subnet/subnet.go @@ -19,11 +19,17 @@ package subnet import ( "fmt" "net" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func GetLiveNetworkSubnets() ([]*net.IPNet, error) { - addrs, err := net.InterfaceAddrs() - if err != nil { + var addrs []net.Addr + if err := rootlessutil.WithDetachedNetNSIfAny(func() error { + var err2 error + addrs, err2 = net.InterfaceAddrs() + return err2 + }); err != nil { return nil, err } nets := make([]*net.IPNet, 0, len(addrs)) @@ -105,12 +111,12 @@ func LastIPInSubnet(addr *net.IPNet) (net.IP, error) { } ones, bits := cidr.Mask.Size() if ones == bits { - return cidr.IP, err + return cidr.IP, nil } for i := range cidr.IP { cidr.IP[i] = cidr.IP[i] | ^cidr.Mask[i] } - return cidr.IP, err + return cidr.IP, nil } // firstIPInSubnet gets the first IP in a subnet @@ -123,8 +129,8 @@ func FirstIPInSubnet(addr *net.IPNet) (net.IP, error) { } ones, bits := cidr.Mask.Size() if ones == bits { - return cidr.IP, err + return cidr.IP, nil } cidr.IP[len(cidr.IP)-1]++ - return cidr.IP, err + return cidr.IP, nil } diff --git a/pkg/nsutil/nsutil.go b/pkg/nsutil/nsutil.go deleted file mode 100644 index 9cde4583c87..00000000000 --- a/pkg/nsutil/nsutil.go +++ /dev/null @@ -1,47 +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 nsutil provides utilities for namespaces. -package nsutil - -import ( - "fmt" - "strings" -) - -// Ensures the provided namespace name is valid. -// Namespace names cannot be path-like strings or pre-defined aliases such as "..". -// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#path-segment-names -func ValidateNamespaceName(nsName string) error { - if nsName == "" { - return fmt.Errorf("namespace name cannot be empty") - } - - // Slash and '$' for POSIX and backslash and '%' for Windows. - pathSeparators := "/\\%$" - if strings.ContainsAny(nsName, pathSeparators) { - return fmt.Errorf("namespace name cannot contain any special characters (%q): %s", pathSeparators, nsName) - } - - specialAliases := []string{".", "..", "~"} - for _, alias := range specialAliases { - if nsName == alias { - return fmt.Errorf("namespace name cannot be special path alias %q", alias) - } - } - - return nil -} diff --git a/pkg/nsutil/nsutil_test.go b/pkg/nsutil/nsutil_test.go deleted file mode 100644 index 4c3645faf6e..00000000000 --- a/pkg/nsutil/nsutil_test.go +++ /dev/null @@ -1,59 +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 nsutil_test - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/nsutil" - "gotest.tools/v3/assert" -) - -func TestValidateNamespaceName(t *testing.T) { - testCases := []struct { - inputs []string - errSubstr string - }{ - { - []string{"test", "test-hyphen", ".start.dot", "mid.dot", "end.dot."}, - "", - }, - { - []string{".", "..", "~"}, - "namespace name cannot be special path alias", - }, - { - []string{"$$", "a$VARiable", "a%VAR%iable", "\\.", "\\%", "\\$"}, - "namespace name cannot contain any special characters", - }, - { - []string{"/start", "mid/dle", "end/", "\\start", "mid\\dle", "end\\"}, - "namespace name cannot contain any special characters", - }, - } - - for _, tc := range testCases { - for _, input := range tc.inputs { - err := nsutil.ValidateNamespaceName(input) - if tc.errSubstr == "" { - assert.NilError(t, err) - } else { - assert.ErrorContains(t, err, tc.errSubstr) - } - } - } -} diff --git a/pkg/ocihook/ocihook.go b/pkg/ocihook/ocihook.go index 4d7e3430ba0..bcebedca18c 100644 --- a/pkg/ocihook/ocihook.go +++ b/pkg/ocihook/ocihook.go @@ -26,21 +26,26 @@ import ( "os" "path/filepath" "strings" + "time" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/bypass4netnsutil" - "github.com/containerd/nerdctl/pkg/dnsutil/hostsstore" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/namestore" - "github.com/containerd/nerdctl/pkg/netutil" - "github.com/containerd/nerdctl/pkg/netutil/nettype" - "github.com/containerd/nerdctl/pkg/rootlessutil" types100 "github.com/containernetworking/cni/pkg/types/100" "github.com/opencontainers/runtime-spec/specs-go" - b4nndclient "github.com/rootless-containers/bypass4netns/pkg/api/daemon/client" - rlkclient "github.com/rootless-containers/rootlesskit/pkg/api/client" - "github.com/sirupsen/logrus" + rlkclient "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" + + "github.com/containerd/go-cni" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/bypass4netnsutil" + "github.com/containerd/nerdctl/v2/pkg/dnsutil/hostsstore" + "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/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/store" ) const ( @@ -56,7 +61,7 @@ const ( NetworkNamespace = labels.Prefix + "network-namespace" ) -func Run(stdin io.Reader, stderr io.Writer, event, dataStore, cniPath, cniNetconfPath string) error { +func Run(stdin io.Reader, stderr io.Writer, event, dataStore, cniPath, cniNetconfPath, bridgeIP string) error { if stdin == nil || event == "" || dataStore == "" || cniPath == "" || cniNetconfPath == "" { return errors.New("got insufficient args") } @@ -78,10 +83,37 @@ func Run(stdin io.Reader, stderr io.Writer, event, dataStore, cniPath, cniNetcon if err != nil { return err } - defer logFile.Close() - logrus.SetOutput(io.MultiWriter(stderr, logFile)) + currentOutput := log.L.Logger.Out + log.L.Logger.SetOutput(io.MultiWriter(stderr, logFile)) + defer func() { + log.L.Logger.SetOutput(currentOutput) + err = logFile.Close() + if err != nil { + log.L.Logger.WithError(err).Error("failed closing oci hook log file") + } + }() + + // FIXME: CNI plugins are not safe to use concurrently + // See + // https://github.com/containerd/nerdctl/issues/3518 + // https://github.com/containerd/nerdctl/issues/2908 + // and likely others + // Fixing these issues would require a lot of work, possibly even stopping using individual cni binaries altogether + // or at least being very mindful in what operation we call inside CNIEnv at what point, with filesystem locking. + // 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. + err = os.MkdirAll(cniNetconfPath, 0o700) + if err != nil { + return err + } + lock, err := lockutil.Lock(cniNetconfPath) + if err != nil { + return err + } + defer lockutil.Unlock(lock) - opts, err := newHandlerOpts(&state, dataStore, cniPath, cniNetconfPath) + opts, err := newHandlerOpts(&state, dataStore, cniPath, cniNetconfPath, bridgeIP) if err != nil { return err } @@ -96,7 +128,7 @@ func Run(stdin io.Reader, stderr io.Writer, event, dataStore, cniPath, cniNetcon } } -func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath string) (*handlerOpts, error) { +func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, bridgeIP string) (*handlerOpts, error) { o := &handlerOpts{ state: state, dataStore: dataStore, @@ -138,34 +170,30 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath strin } switch netType { - case nettype.Host, nettype.None, nettype.Container: + case nettype.Host, nettype.None, nettype.Container, nettype.Namespace: // NOP case nettype.CNI: - e, err := netutil.NewCNIEnv(cniPath, cniNetconfPath, netutil.WithDefaultNetwork()) + e, err := netutil.NewCNIEnv(cniPath, cniNetconfPath, netutil.WithNamespace(namespace), netutil.WithDefaultNetwork(bridgeIP)) if err != nil { return nil, err } - cniOpts := []gocni.Opt{ - gocni.WithPluginDir([]string{cniPath}), - } - netMap, err := e.NetworkMap() - if err != nil { - return nil, err + cniOpts := []cni.Opt{ + cni.WithPluginDir([]string{cniPath}), } + var netw *netutil.NetworkConfig for _, netstr := range networks { - net, ok := netMap[netstr] - if !ok { - return nil, fmt.Errorf("no such network: %q", netstr) + if netw, err = e.NetworkByNameOrID(netstr); err != nil { + return nil, err } - cniOpts = append(cniOpts, gocni.WithConfListBytes(net.Bytes)) + cniOpts = append(cniOpts, cni.WithConfListBytes(netw.Bytes)) o.cniNames = append(o.cniNames, netstr) } - o.cni, err = gocni.New(cniOpts...) + o.cni, err = cni.New(cniOpts...) if err != nil { return nil, err } if o.cni == nil { - logrus.Warnf("no CNI network could be loaded from the provided network names: %v", networks) + log.L.Warnf("no CNI network could be loaded from the provided network names: %v", networks) } default: return nil, fmt.Errorf("unexpected network type %v", netType) @@ -188,7 +216,11 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath strin } if macAddress, ok := o.state.Annotations[labels.MACAddress]; ok { - o.contianerMAC = macAddress + o.containerMAC = macAddress + } + + if ip6Address, ok := o.state.Annotations[labels.IP6Address]; ok { + o.containerIP6 = ip6Address } if rootlessutil.IsRootlessChild() { @@ -196,7 +228,7 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath strin if err != nil { return nil, err } - b4nnEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(o.state.Annotations) + b4nnEnabled, _, err := bypass4netnsutil.IsBypass4netnsEnabled(o.state.Annotations) if err != nil { return nil, err } @@ -218,15 +250,16 @@ type handlerOpts struct { state *specs.State dataStore string rootfs string - ports []gocni.PortMapping - cni gocni.CNI + ports []cni.PortMapping + cni cni.CNI cniNames []string fullID string rootlessKitClient rlkclient.Client bypassClient b4nndclient.Client extraHosts map[string]string // host:ip containerIP string - contianerMAC string + containerMAC string + containerIP6 string } // hookSpec is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/containerd/command/oci-hook.go#L59-L64 @@ -277,7 +310,7 @@ func getNetNSPath(state *specs.State) (string, error) { return netNsPath, nil } - if state.Pid == 0 && !netNsFound { + if state.Pid == 0 { return "", errors.New("both state.Pid and the netNs annotation are unset") } @@ -289,10 +322,10 @@ func getNetNSPath(state *specs.State) (string, error) { return s, nil } -func getPortMapOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { +func getPortMapOpts(opts *handlerOpts) ([]cni.NamespaceOpts, error) { if len(opts.ports) > 0 { if !rootlessutil.IsRootlessChild() { - return []gocni.NamespaceOpts{gocni.WithCapabilityPortMap(opts.ports)}, nil + return []cni.NamespaceOpts{cni.WithCapabilityPortMap(opts.ports)}, nil } var ( childIP net.IP @@ -300,7 +333,7 @@ func getPortMapOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { ) info, err := opts.rootlessKitClient.Info(context.TODO()) if err != nil { - logrus.WithError(err).Warn("cannot call RootlessKit Info API, make sure you have RootlessKit v0.14.1 or later") + log.L.WithError(err).Warn("cannot call RootlessKit Info API, make sure you have RootlessKit v0.14.1 or later") } else { childIP = info.NetworkDriver.ChildIP portDriverDisallowsLoopbackChildIP = info.PortDriver.DisallowLoopbackChildIP // true for slirp4netns port driver @@ -310,14 +343,12 @@ func getPortMapOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { // // We must NOT modify opts.ports here, because we use the unmodified opts.ports for // interaction with RootlessKit API. - ports := make([]gocni.PortMapping, len(opts.ports)) + ports := make([]cni.PortMapping, len(opts.ports)) for i, p := range opts.ports { if hostIP := net.ParseIP(p.HostIP); hostIP != nil && !hostIP.IsUnspecified() { // loopback address is always bindable in the child namespace, but other addresses are unlikely. if !hostIP.IsLoopback() { - if childIP != nil && childIP.Equal(hostIP) { - // this is fine - } else { + if !(childIP != nil && childIP.Equal(hostIP)) { if portDriverDisallowsLoopbackChildIP { p.HostIP = childIP.String() } else { @@ -330,131 +361,225 @@ func getPortMapOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { } ports[i] = p } - return []gocni.NamespaceOpts{gocni.WithCapabilityPortMap(ports)}, nil + return []cni.NamespaceOpts{cni.WithCapabilityPortMap(ports)}, nil } return nil, nil } -func getIPAddressOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { +func getIPAddressOpts(opts *handlerOpts) ([]cni.NamespaceOpts, error) { if opts.containerIP != "" { if rootlessutil.IsRootlessChild() { - logrus.Debug("container IP assignment is not fully supported in rootless mode. The IP is not accessible from the host (but still accessible from other containers).") + log.L.Debug("container IP assignment is not fully supported in rootless mode. The IP is not accessible from the host (but still accessible from other containers).") } - return []gocni.NamespaceOpts{ - gocni.WithLabels(map[string]string{ + return []cni.NamespaceOpts{ + cni.WithLabels(map[string]string{ // Special tick for go-cni. Because go-cni marks all labels and args as same // So, we need add a special label to pass the containerIP to the host-local plugin. // FYI: https://github.com/containerd/go-cni/blob/v1.1.3/README.md?plain=1#L57-L64 "IgnoreUnknown": "1", }), - gocni.WithArgs("IP", opts.containerIP), + cni.WithArgs("IP", opts.containerIP), }, nil } return nil, nil } -func getMACAddressOpts(opts *handlerOpts) ([]gocni.NamespaceOpts, error) { - if opts.contianerMAC != "" { - return []gocni.NamespaceOpts{ - gocni.WithLabels(map[string]string{ +func getMACAddressOpts(opts *handlerOpts) ([]cni.NamespaceOpts, error) { + if opts.containerMAC != "" { + return []cni.NamespaceOpts{ + cni.WithLabels(map[string]string{ // allow loose CNI argument verification // FYI: https://github.com/containernetworking/cni/issues/560 "IgnoreUnknown": "1", }), - gocni.WithArgs("MAC", opts.contianerMAC), + cni.WithArgs("MAC", opts.containerMAC), }, nil } return nil, nil } -func onCreateRuntime(opts *handlerOpts) error { - loadAppArmor() - - if opts.cni != nil { - portMapOpts, err := getPortMapOpts(opts) - if err != nil { - return err - } - nsPath, err := getNetNSPath(opts.state) - if err != nil { - return err - } - ctx := context.Background() - hs, err := hostsstore.NewStore(opts.dataStore) - if err != nil { - return err - } - ipAddressOpts, err := getIPAddressOpts(opts) - if err != nil { - return err - } - macAddressOpts, err := getMACAddressOpts(opts) - if err != nil { - return err - } - var namespaceOpts []gocni.NamespaceOpts - namespaceOpts = append(namespaceOpts, portMapOpts...) - namespaceOpts = append(namespaceOpts, ipAddressOpts...) - namespaceOpts = append(namespaceOpts, macAddressOpts...) - hsMeta := hostsstore.Meta{ - Namespace: opts.state.Annotations[labels.Namespace], - ID: opts.state.ID, - Networks: make(map[string]*types100.Result, len(opts.cniNames)), - Hostname: opts.state.Annotations[labels.Hostname], - ExtraHosts: opts.extraHosts, - Name: opts.state.Annotations[labels.Name], - } - 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] +func getIP6AddressOpts(opts *handlerOpts) ([]cni.NamespaceOpts, error) { + if opts.containerIP6 != "" { + if rootlessutil.IsRootlessChild() { + log.L.Debug("container IP6 assignment is not fully supported in rootless mode. The IP6 is not accessible from the host (but still accessible from other containers).") } + return []cni.NamespaceOpts{ + cni.WithLabels(map[string]string{ + // allow loose CNI argument verification + // FYI: https://github.com/containernetworking/cni/issues/560 + "IgnoreUnknown": "1", + }), + cni.WithCapability("ips", []string{opts.containerIP6}), + }, nil + } + return nil, nil +} - b4nnEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(opts.state.Annotations) - if err != nil { - return err - } +func applyNetworkSettings(opts *handlerOpts) error { + portMapOpts, err := getPortMapOpts(opts) + if err != nil { + return err + } + nsPath, err := getNetNSPath(opts.state) + if err != nil { + return err + } + ctx := context.Background() + hs, err := hostsstore.New(opts.dataStore, opts.state.Annotations[labels.Namespace]) + if err != nil { + return err + } + ipAddressOpts, err := getIPAddressOpts(opts) + if err != nil { + return err + } + macAddressOpts, err := getMACAddressOpts(opts) + if err != nil { + return err + } + ip6AddressOpts, err := getIP6AddressOpts(opts) + if err != nil { + return err + } + var namespaceOpts []cni.NamespaceOpts + namespaceOpts = append(namespaceOpts, portMapOpts...) + namespaceOpts = append(namespaceOpts, ipAddressOpts...) + namespaceOpts = append(namespaceOpts, macAddressOpts...) + namespaceOpts = append(namespaceOpts, ip6AddressOpts...) + namespaceOpts = append(namespaceOpts, + cni.WithLabels(map[string]string{ + "IgnoreUnknown": "1", + }), + cni.WithArgs("NERDCTL_CNI_DHCP_HOSTNAME", opts.state.Annotations[labels.Hostname]), + ) + hsMeta := hostsstore.Meta{ + ID: opts.state.ID, + Networks: make(map[string]*types100.Result, len(opts.cniNames)), + Hostname: opts.state.Annotations[labels.Hostname], + ExtraHosts: opts.extraHosts, + Name: opts.state.Annotations[labels.Name], + } + + // When containerd gets bounced, containers that were previously running and that are restarted will go again + // through onCreateRuntime (*unlike* in a normal stop/start flow). + // As such, a container may very well have an ip already. The bridge plugin would thus refuse to loan a new one + // and error out, thus making the onCreateRuntime hook fail. In turn, runc (or containerd) will mis-interpret this, + // and subsequently call onPostStop (although the container will not get deleted), and we will release the name... + // leading to a bricked system where multiple containers may share the same name. + // Thus, we do pre-emptively clean things up - error is not checked, as in the majority of cases, that would + // legitimately error (and that does not matter) + // See https://github.com/containerd/nerdctl/issues/3355 + _ = opts.cni.Remove(ctx, opts.fullID, "", 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] + } - if err := hs.Acquire(hsMeta); err != nil { - return err - } + b4nnEnabled, b4nnBindEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(opts.state.Annotations) + if err != nil { + return err + } - if rootlessutil.IsRootlessChild() { - if b4nnEnabled { - bm, err := bypass4netnsutil.NewBypass4netnsCNIBypassManager(opts.bypassClient, opts.rootlessKitClient) - if err != nil { - return err - } - err = bm.StartBypass(ctx, opts.ports, opts.state.ID, opts.state.Annotations[labels.StateDir]) - if err != nil { - return fmt.Errorf("bypass4netnsd not running? (Hint: run `containerd-rootless-setuptool.sh install-bypass4netnsd`): %w", err) - } - } else if len(opts.ports) > 0 { - if err := exposePortsRootless(ctx, opts.rootlessKitClient, opts.ports); err != nil { - return fmt.Errorf("failed to expose ports in rootless mode: %s", err) - } + if err := hs.Acquire(hsMeta); err != nil { + return err + } + + if rootlessutil.IsRootlessChild() { + if b4nnEnabled { + bm, err := bypass4netnsutil.NewBypass4netnsCNIBypassManager(opts.bypassClient, opts.rootlessKitClient, opts.state.Annotations) + if err != nil { + return err + } + err = bm.StartBypass(ctx, opts.ports, opts.state.ID, opts.state.Annotations[labels.StateDir]) + if err != nil { + return fmt.Errorf("bypass4netnsd not running? (Hint: run `containerd-rootless-setuptool.sh install-bypass4netnsd`): %w", err) + } + } + if !b4nnBindEnabled && len(opts.ports) > 0 { + if err := exposePortsRootless(ctx, opts.rootlessKitClient, opts.ports); err != nil { + return fmt.Errorf("failed to expose ports in rootless mode: %s", err) } } } return nil } +func onCreateRuntime(opts *handlerOpts) error { + loadAppArmor() + + name := opts.state.Annotations[labels.Name] + ns := opts.state.Annotations[labels.Namespace] + namst, err := namestore.New(opts.dataStore, ns) + if err != nil { + log.L.WithError(err).Error("failed opening the namestore in onCreateRuntime") + } else if err := namst.Acquire(name, opts.state.ID); err != nil { + log.L.WithError(err).Error("failed re-acquiring name - see https://github.com/containerd/nerdctl/issues/2992") + } + + var netError error + if opts.cni != nil { + netError = applyNetworkSettings(opts) + } + + // Set StartedAt and CreateError + lf, err := state.New(opts.state.Annotations[labels.StateDir]) + if err != nil { + return err + } + + err = lf.Transform(func(lf *state.Store) error { + lf.StartedAt = time.Now() + lf.CreateError = netError != nil + return nil + }) + if err != nil { + return err + } + + return netError +} + func onPostStop(opts *handlerOpts) error { + lf, err := state.New(opts.state.Annotations[labels.StateDir]) + if err != nil { + return err + } + + var shouldExit bool + err = lf.Transform(func(lf *state.Store) error { + // See https://github.com/containerd/nerdctl/issues/3357 + // Check if we actually errored during runtimeCreate + // If that is the case, CreateError is set, and we are in postStop while the container will NOT be deleted (see ticket). + // Thus, do NOT treat this as a deletion, as the container is still there. + // Reset CreateError, and return. + shouldExit = lf.CreateError + lf.CreateError = false + return nil + }) + if err != nil { + return err + } + if shouldExit { + return nil + } + ctx := context.Background() ns := opts.state.Annotations[labels.Namespace] if opts.cni != nil { var err error - b4nnEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(opts.state.Annotations) + b4nnEnabled, b4nnBindEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(opts.state.Annotations) if err != nil { return err } if rootlessutil.IsRootlessChild() { if b4nnEnabled { - bm, err := bypass4netnsutil.NewBypass4netnsCNIBypassManager(opts.bypassClient, opts.rootlessKitClient) + bm, err := bypass4netnsutil.NewBypass4netnsCNIBypassManager(opts.bypassClient, opts.rootlessKitClient, opts.state.Annotations) if err != nil { return err } @@ -462,7 +587,8 @@ func onPostStop(opts *handlerOpts) error { if err != nil { return err } - } else if len(opts.ports) > 0 { + } + if !b4nnBindEnabled && len(opts.ports) > 0 { if err := unexposePortsRootless(ctx, opts.rootlessKitClient, opts.ports); err != nil { return fmt.Errorf("failed to unexpose ports in rootless mode: %s", err) } @@ -480,19 +606,24 @@ func onPostStop(opts *handlerOpts) error { if err != nil { return err } - var namespaceOpts []gocni.NamespaceOpts + ip6AddressOpts, err := getIP6AddressOpts(opts) + if err != nil { + return err + } + var namespaceOpts []cni.NamespaceOpts namespaceOpts = append(namespaceOpts, portMapOpts...) namespaceOpts = append(namespaceOpts, ipAddressOpts...) namespaceOpts = append(namespaceOpts, macAddressOpts...) + namespaceOpts = append(namespaceOpts, ip6AddressOpts...) if err := opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...); err != nil { - logrus.WithError(err).Errorf("failed to call cni.Remove") + log.L.WithError(err).Errorf("failed to call cni.Remove") return err } - hs, err := hostsstore.NewStore(opts.dataStore) + hs, err := hostsstore.New(opts.dataStore, ns) if err != nil { return err } - if err := hs.Release(ns, opts.state.ID); err != nil { + if err := hs.Release(opts.state.ID); err != nil { return err } } @@ -501,7 +632,8 @@ func onPostStop(opts *handlerOpts) error { return err } name := opts.state.Annotations[labels.Name] - if err := namst.Release(name, opts.state.ID); err != nil { + // Double-releasing may happen with containers started with --rm, so, ignore NotFound errors + 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) } return nil @@ -519,7 +651,7 @@ func writePidFile(path string, pid int) error { if err != nil { return err } - _, err = fmt.Fprintf(f, "%d", pid) + _, err = fmt.Fprint(f, pid) f.Close() if err != nil { return err diff --git a/pkg/ocihook/ocihook_freebsd.go b/pkg/ocihook/ocihook_freebsd.go index ef9df017c98..03323a67215 100644 --- a/pkg/ocihook/ocihook_freebsd.go +++ b/pkg/ocihook/ocihook_freebsd.go @@ -18,5 +18,4 @@ package ocihook func loadAppArmor() { //noop - return } diff --git a/pkg/ocihook/ocihook_linux.go b/pkg/ocihook/ocihook_linux.go index eb8d531533b..da961d75435 100644 --- a/pkg/ocihook/ocihook_linux.go +++ b/pkg/ocihook/ocihook_linux.go @@ -17,11 +17,11 @@ package ocihook import ( - "github.com/containerd/containerd/contrib/apparmor" - "github.com/containerd/nerdctl/pkg/apparmorutil" - "github.com/containerd/nerdctl/pkg/defaults" + "github.com/containerd/containerd/v2/contrib/apparmor" + "github.com/containerd/log" - "github.com/sirupsen/logrus" + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" + "github.com/containerd/nerdctl/v2/pkg/defaults" ) func loadAppArmor() { @@ -30,7 +30,7 @@ func loadAppArmor() { } // ensure that the default profile is loaded to the host if err := apparmor.LoadDefaultProfile(defaults.AppArmorProfileName); err != nil { - logrus.WithError(err).Errorf("failed to load AppArmor profile %q", defaults.AppArmorProfileName) + log.L.WithError(err).Errorf("failed to load AppArmor profile %q", defaults.AppArmorProfileName) // We do not abort here. This is by design, and not a security issue. // // If the container is configured to use the default AppArmor profile diff --git a/pkg/ocihook/ocihook_windows.go b/pkg/ocihook/ocihook_windows.go index ef9df017c98..03323a67215 100644 --- a/pkg/ocihook/ocihook_windows.go +++ b/pkg/ocihook/ocihook_windows.go @@ -18,5 +18,4 @@ package ocihook func loadAppArmor() { //noop - return } diff --git a/pkg/ocihook/rootless_linux.go b/pkg/ocihook/rootless_linux.go index 2b1f4db7730..5f908e62d15 100644 --- a/pkg/ocihook/rootless_linux.go +++ b/pkg/ocihook/rootless_linux.go @@ -19,12 +19,14 @@ package ocihook import ( "context" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/rootlessutil" - rlkclient "github.com/rootless-containers/rootlesskit/pkg/api/client" + rlkclient "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" + + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) -func exposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []gocni.PortMapping) error { +func exposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []cni.PortMapping) error { pm, err := rootlessutil.NewRootlessCNIPortManager(rlkClient) if err != nil { return err @@ -38,7 +40,7 @@ func exposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports return nil } -func unexposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []gocni.PortMapping) error { +func unexposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []cni.PortMapping) error { pm, err := rootlessutil.NewRootlessCNIPortManager(rlkClient) if err != nil { return err diff --git a/pkg/ocihook/rootless_other.go b/pkg/ocihook/rootless_other.go index c3dbe214d54..ed1485a958a 100644 --- a/pkg/ocihook/rootless_other.go +++ b/pkg/ocihook/rootless_other.go @@ -22,14 +22,15 @@ import ( "context" "fmt" - gocni "github.com/containerd/go-cni" - rlkclient "github.com/rootless-containers/rootlesskit/pkg/api/client" + rlkclient "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" + + "github.com/containerd/go-cni" ) -func exposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []gocni.PortMapping) error { +func exposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []cni.PortMapping) error { return fmt.Errorf("cannot expose ports rootlessly on non-Linux hosts") } -func unexposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []gocni.PortMapping) error { +func unexposePortsRootless(ctx context.Context, rlkClient rlkclient.Client, ports []cni.PortMapping) error { return fmt.Errorf("cannot unexpose ports rootlessly on non-Linux hosts") } diff --git a/pkg/ocihook/state/state.go b/pkg/ocihook/state/state.go new file mode 100644 index 00000000000..52253d390fe --- /dev/null +++ b/pkg/ocihook/state/state.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 state provides a store to retrieve and save container lifecycle related information +// This is typically used by oci-hooks for information that cannot be retrieved / updated otherwise +// Specifically, the state carries container start time, and transient information about possible failures during +// hook events processing. +// All store methods are safe to use concurrently and only write atomically. +// Since the state is transient and carrying solely informative data, errors returned from here could be treated as +// soft-failures. +// Note that locking is done at the container state directory level. +// state is currently used by ocihooks and for read by dockercompat (to display started-at time) +package state + +import ( + "encoding/json" + "errors" + "time" + + "github.com/containerd/nerdctl/v2/pkg/store" +) + +// lifecycleFile is the name of file carrying the container information, relative to stateDir +const lifecycleFile = "lifecycle.json" + +// ErrLifecycleStore will wrap all errors here +var ErrLifecycleStore = errors.New("lifecycle-store error") + +// New will return a lifecycle struct for the container which stateDir is passed as argument +func New(stateDir string) (*Store, error) { + st, err := store.New(stateDir, 0, 0) + if err != nil { + return nil, errors.Join(ErrLifecycleStore, err) + } + + return &Store{ + safeStore: st, + }, nil +} + +// Store exposes methods to retrieve and transform state information about containers. +type Store struct { + safeStore store.Store + + // StartedAt reflects the time at which we received the oci-hook onCreateRuntime event + StartedAt time.Time `json:"started_at"` + CreateError bool `json:"create_error"` +} + +// Load will populate the struct with existing in-store lifecycle information +func (lf *Store) Load() (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrLifecycleStore, err) + } + }() + + return lf.safeStore.WithLock(lf.rawLoad) +} + +// Transform should be used to perform random mutations +func (lf *Store) Transform(fun func(lf *Store) error) (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrLifecycleStore, err) + } + }() + + return lf.safeStore.WithLock(func() error { + err = lf.rawLoad() + if err != nil { + return err + } + err = fun(lf) + if err != nil { + return err + } + return lf.rawSave() + }) +} + +// Delete will destroy the lifecycle data +func (lf *Store) Delete() (err error) { + defer func() { + if err != nil { + err = errors.Join(ErrLifecycleStore, err) + } + }() + + return lf.safeStore.WithLock(lf.rawDelete) +} + +func (lf *Store) rawLoad() (err error) { + data, err := lf.safeStore.Get(lifecycleFile) + if err == nil { + err = json.Unmarshal(data, lf) + } else if errors.Is(err, store.ErrNotFound) { + err = nil + } + + return err +} + +func (lf *Store) rawSave() (err error) { + data, err := json.Marshal(lf) + if err != nil { + return err + } + return lf.safeStore.Set(data, lifecycleFile) +} + +func (lf *Store) rawDelete() (err error) { + return lf.safeStore.Delete(lifecycleFile) +} diff --git a/pkg/platformutil/binfmt.go b/pkg/platformutil/binfmt.go index 11c05b94cf4..1539426790d 100644 --- a/pkg/platformutil/binfmt.go +++ b/pkg/platformutil/binfmt.go @@ -21,7 +21,7 @@ import ( "os" "runtime" - "github.com/containerd/containerd/platforms" + "github.com/containerd/platforms" ) func qemuArchFromOCIArch(ociArch string) (string, error) { @@ -36,6 +36,8 @@ func qemuArchFromOCIArch(ociArch string) (string, error) { return ociArch, nil case "mips64le": return "mips64el", nil // NOT typo + case "loong64": + return "loongarch64", nil // NOT typo } return "", fmt.Errorf("unknown OCI architecture string: %q", ociArch) } diff --git a/pkg/platformutil/layers.go b/pkg/platformutil/layers.go index 1aa18d1504c..7bb6956bc10 100644 --- a/pkg/platformutil/layers.go +++ b/pkg/platformutil/layers.go @@ -19,10 +19,11 @@ package platformutil import ( "context" - "github.com/containerd/containerd/content" - "github.com/containerd/containerd/images" - "github.com/containerd/containerd/platforms" 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/platforms" ) func LayerDescs(ctx context.Context, provider content.Provider, imageTarget ocispec.Descriptor, platform platforms.MatchComparer) ([]ocispec.Descriptor, error) { diff --git a/pkg/platformutil/platformutil.go b/pkg/platformutil/platformutil.go index 13e90dcdc15..ac076d0b980 100644 --- a/pkg/platformutil/platformutil.go +++ b/pkg/platformutil/platformutil.go @@ -19,9 +19,11 @@ package platformutil import ( "fmt" - "github.com/containerd/containerd/platforms" - "github.com/containerd/nerdctl/pkg/strutil" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/strutil" ) // NewMatchComparerFromOCISpecPlatformSlice returns MatchComparer. diff --git a/pkg/portutil/iptable/iptables.go b/pkg/portutil/iptable/iptables.go index 1d90ccb7f24..2c5daf01cef 100644 --- a/pkg/portutil/iptable/iptables.go +++ b/pkg/portutil/iptable/iptables.go @@ -19,6 +19,7 @@ package iptable import ( "regexp" "strconv" + "strings" ) // ParseIPTableRules takes a slice of iptables rules as input and returns a slice of @@ -27,16 +28,18 @@ func ParseIPTableRules(rules []string) []uint64 { ports := []uint64{} // Regex to match the '--dports' option followed by the port number - dportRegex := regexp.MustCompile(`--dports (\d+)`) + dportRegex := regexp.MustCompile(`--dports ((,?\d+)+)`) for _, rule := range rules { matches := dportRegex.FindStringSubmatch(rule) if len(matches) > 1 { - port64, err := strconv.ParseUint(matches[1], 10, 16) - if err != nil { - continue + for _, _match := range strings.Split(matches[1], ",") { + port64, err := strconv.ParseUint(_match, 10, 16) + if err != nil { + continue + } + ports = append(ports, port64) } - ports = append(ports, port64) } } diff --git a/pkg/portutil/port_allocate_linux.go b/pkg/portutil/port_allocate_linux.go index c3c6ca25e52..bd396a52555 100644 --- a/pkg/portutil/port_allocate_linux.go +++ b/pkg/portutil/port_allocate_linux.go @@ -19,14 +19,17 @@ package portutil import ( "fmt" - "github.com/containerd/nerdctl/pkg/portutil/iptable" - "github.com/containerd/nerdctl/pkg/portutil/procnet" + "github.com/containerd/nerdctl/v2/pkg/portutil/iptable" + "github.com/containerd/nerdctl/v2/pkg/portutil/procnet" ) 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 +) + +var ( allocateStart = 49153 - allocateEnd = 60999 ) func filter(ss []procnet.NetworkDetail, filterFunc func(detail procnet.NetworkDetail) bool) (ret []procnet.NetworkDetail) { @@ -96,6 +99,7 @@ func portAllocate(protocol string, ip string, count uint64) (uint64, uint64, err } } if needReturn { + allocateStart = int(start + count) return start, start + count - 1, nil } start += count diff --git a/pkg/portutil/port_allocate_others.go b/pkg/portutil/port_allocate_other.go similarity index 100% rename from pkg/portutil/port_allocate_others.go rename to pkg/portutil/port_allocate_other.go diff --git a/pkg/portutil/portutil.go b/pkg/portutil/portutil.go index da1c5a8761d..28a1836bb2f 100644 --- a/pkg/portutil/portutil.go +++ b/pkg/portutil/portutil.go @@ -22,34 +22,35 @@ import ( "net" "strings" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/rootlessutil" "github.com/docker/go-connections/nat" - "github.com/sirupsen/logrus" + + "github.com/containerd/go-cni" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) // return respectively ip, hostPort, containerPort func splitParts(rawport string) (string, string, string) { - parts := strings.Split(rawport, ":") - n := len(parts) - containerport := parts[n-1] + lastIndex := strings.LastIndex(rawport, ":") + containerPort := rawport[lastIndex+1:] + if lastIndex == -1 { + return "", "", containerPort + } - switch n { - case 1: - return "", "", containerport - case 2: - return "", parts[0], containerport - case 3: - return parts[0], parts[1], containerport - default: - return strings.Join(parts[:n-2], ":"), parts[n-2], containerport + hostAddrPort := rawport[:lastIndex] + addr, port, err := net.SplitHostPort(hostAddrPort) + if err != nil { + return "", hostAddrPort, containerPort } + + return addr, port, containerPort } // ParseFlagP parse port mapping pair, like "127.0.0.1:3000:8080/tcp", // "127.0.0.1:3000-3001:8080-8081/tcp" and "3000:8080" ... -func ParseFlagP(s string) ([]gocni.PortMapping, error) { +func ParseFlagP(s string) ([]cni.PortMapping, error) { proto := "tcp" splitBySlash := strings.Split(s, "/") switch len(splitBySlash) { @@ -66,11 +67,11 @@ func ParseFlagP(s string) ([]gocni.PortMapping, error) { return nil, fmt.Errorf("failed to parse %q, unexpected slashes", s) } - res := gocni.PortMapping{ + res := cni.PortMapping{ Protocol: proto, } - mr := []gocni.PortMapping{} + mr := []cni.PortMapping{} ip, hostPort, containerPort := splitParts(splitBySlash[0]) @@ -94,7 +95,7 @@ func ParseFlagP(s string) ([]gocni.PortMapping, error) { if err != nil { return nil, err } - logrus.Debugf("There is no hostPort has been spec in command, the auto allocate port is from %d:%d to %d:%d", startHostPort, startPort, endHostPort, endPort) + log.L.Debugf("There is no hostPort has been spec in command, the auto allocate port is from %d:%d to %d:%d", startHostPort, startPort, endHostPort, endPort) } else { startHostPort, endHostPort, err = nat.ParsePortRange(hostPort) if err != nil { @@ -129,13 +130,13 @@ func ParseFlagP(s string) ([]gocni.PortMapping, error) { } // ParsePortsLabel parses JSON-marshalled string from label map -// (under `labels.Ports` key) and returns []gocni.PortMapping. -func ParsePortsLabel(labelMap map[string]string) ([]gocni.PortMapping, error) { +// (under `labels.Ports` key) and returns []cni.PortMapping. +func ParsePortsLabel(labelMap map[string]string) ([]cni.PortMapping, error) { portsJSON := labelMap[labels.Ports] if portsJSON == "" { - return []gocni.PortMapping{}, nil + return []cni.PortMapping{}, nil } - var ports []gocni.PortMapping + var ports []cni.PortMapping 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()) } diff --git a/pkg/portutil/portutil_test.go b/pkg/portutil/portutil_test.go index 8b15c07596c..d14c79786c3 100644 --- a/pkg/portutil/portutil_test.go +++ b/pkg/portutil/portutil_test.go @@ -22,9 +22,10 @@ import ( "sort" "testing" - gocni "github.com/containerd/go-cni" - "github.com/containerd/nerdctl/pkg/labels" - "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/go-cni" + + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) func TestTestParseFlagPWithPlatformSpec(t *testing.T) { @@ -37,7 +38,7 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { tests := []struct { name string args args - want []gocni.PortMapping + want []cni.PortMapping wantErr bool }{ { @@ -45,7 +46,7 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { args: args{ s: "3000", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 3000, @@ -60,7 +61,7 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { args: args{ s: "3000-3001", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 3000, @@ -89,7 +90,7 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { args: args{ s: "3000-3001/tcp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 3000, @@ -110,7 +111,7 @@ func TestTestParseFlagPWithPlatformSpec(t *testing.T) { args: args{ s: "3000-3001/udp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 3000, @@ -167,7 +168,7 @@ func TestParsePortsLabel(t *testing.T) { tests := []struct { name string labelMap map[string]string - want []gocni.PortMapping + want []cni.PortMapping wantErr bool }{ { @@ -175,7 +176,7 @@ func TestParsePortsLabel(t *testing.T) { labelMap: map[string]string{ labels.Ports: "[{\"HostPort\":12345,\"ContainerPort\":10000,\"Protocol\":\"tcp\",\"HostIP\":\"0.0.0.0\"}]", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -190,13 +191,13 @@ func TestParsePortsLabel(t *testing.T) { labelMap: map[string]string{ labels.Ports: "", }, - want: []gocni.PortMapping{}, + want: []cni.PortMapping{}, wantErr: false, }, { name: "empty ports (key not exists)", labelMap: map[string]string{}, - want: []gocni.PortMapping{}, + want: []cni.PortMapping{}, wantErr: false, }, { @@ -250,7 +251,7 @@ func TestParseFlagP(t *testing.T) { tests := []struct { name string args args - want []gocni.PortMapping + want []cni.PortMapping wantErr bool }{ { @@ -258,7 +259,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "127.0.0.1:3000:8080/tcp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -273,7 +274,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "127.0.0.1:3000-3001:8080-8081/tcp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -302,7 +303,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "3000:8080/tcp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -317,7 +318,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "3000:8080", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -332,7 +333,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "3000:8080/udp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -347,7 +348,7 @@ func TestParseFlagP(t *testing.T) { args: args{ s: "3000:8080/sctp", }, - want: []gocni.PortMapping{ + want: []cni.PortMapping{ { HostPort: 3000, ContainerPort: 8080, @@ -357,6 +358,21 @@ func TestParseFlagP(t *testing.T) { }, wantErr: false, }, + { + name: "with ipv6 host ip", + args: args{ + s: "[::0]:8080:80/tcp", + }, + want: []cni.PortMapping{ + { + HostPort: 8080, + ContainerPort: 80, + Protocol: "tcp", + HostIP: "::0", + }, + }, + wantErr: false, + }, { name: "with invalid protocol", args: args{ diff --git a/pkg/referenceutil/referenceutil.go b/pkg/referenceutil/referenceutil.go index 86de6558feb..4047a2ccad5 100644 --- a/pkg/referenceutil/referenceutil.go +++ b/pkg/referenceutil/referenceutil.go @@ -17,81 +17,128 @@ package referenceutil import ( - "fmt" + "errors" "path" "strings" - refdocker "github.com/containerd/containerd/reference/docker" + "github.com/distribution/reference" "github.com/ipfs/go-cid" + "github.com/opencontainers/go-digest" ) -// Reference is a reference to an image. -type Reference interface { +type Protocol string - // String returns the full reference which can be understood by containerd. - String() string +const IPFSProtocol Protocol = "ipfs" +const IPNSProtocol Protocol = "ipns" +const shortIDLength = 5 + +var ErrLoadOCIArchiveRequired = errors.New("image must be loaded from archive before parsing image reference") + +type ImageReference struct { + Protocol Protocol + Digest digest.Digest + Tag string + ExplicitTag string + Path string + Domain string + + nn reference.Reference +} + +func (ir *ImageReference) Name() string { + ret := ir.Domain + if ret != "" { + ret += "/" + } + ret += ir.Path + return ret } -// ParseAny parses the passed reference with allowing it to be non-docker reference. -// If the ref has IPFS scheme or can be parsed as CID, it's parsed as an IPFS reference. -// Otherwise it's parsed as a docker reference. -func ParseAny(rawRef string) (Reference, error) { - if scheme, ref, err := ParseIPFSRefWithScheme(rawRef); err == nil { - return stringRef{scheme: scheme, s: ref}, nil +func (ir *ImageReference) FamiliarName() string { + if ir.Protocol != "" && ir.Domain == "" { + return ir.Path } - if c, err := cid.Decode(rawRef); err == nil { - return c, nil + if ir.nn != nil { + return reference.FamiliarName(ir.nn.(reference.Named)) } - return ParseDockerRef(rawRef) + return "" } -// ParseDockerRef parses the passed reference with assuming it's a docker reference. -func ParseDockerRef(rawRef string) (refdocker.Named, error) { - return refdocker.ParseDockerRef(rawRef) +func (ir *ImageReference) FamiliarMatch(pattern string) (bool, error) { + return reference.FamiliarMatch(pattern, ir.nn) } -// ParseIPFSRefWithScheme parses the passed reference with assuming it's an IPFS reference with scheme prefix. -func ParseIPFSRefWithScheme(name string) (scheme, ref string, err error) { - if strings.HasPrefix(name, "ipfs://") || strings.HasPrefix(name, "ipns://") { - return name[:4], name[7:], nil +func (ir *ImageReference) String() string { + if ir.Protocol != "" && ir.Domain == "" { + return ir.Path + } + if ir.Path == "" && ir.Digest != "" { + return ir.Digest.String() + } + if ir.nn != nil { + return ir.nn.String() } - return "", "", fmt.Errorf("reference is not an IPFS reference") + return "" } -type stringRef struct { - scheme string - s string +func (ir *ImageReference) SuggestContainerName(suffix string) string { + name := "untitled" + if ir.Protocol != "" && ir.Domain == "" { + name = string(ir.Protocol) + "-" + ir.String()[:shortIDLength] + } else if ir.Path != "" { + name = path.Base(ir.Path) + } + return name + "-" + suffix[:5] } -func (s stringRef) String() string { - return s.s -} +func Parse(rawRef string) (*ImageReference, error) { + ir := &ImageReference{} -// SuggestContainerName generates a container name from name. -// The result MUST NOT be parsed. -func SuggestContainerName(rawRef, containerID string) string { - const shortIDLength = 5 - if len(containerID) < shortIDLength { - panic(fmt.Errorf("got too short (< %d) container ID: %q", shortIDLength, containerID)) + if strings.HasPrefix(rawRef, "ipfs://") { + ir.Protocol = IPFSProtocol + rawRef = rawRef[7:] + } else if strings.HasPrefix(rawRef, "ipns://") { + ir.Protocol = IPNSProtocol + rawRef = rawRef[7:] + } else if strings.HasPrefix(rawRef, "oci-archive://") { + // The image must be loaded from the specified archive path first + // before parsing the image reference specified in its OCI image manifest. + return nil, ErrLoadOCIArchiveRequired } - name := "untitled-" + containerID[:shortIDLength] - if rawRef != "" { - r, err := ParseAny(rawRef) - if err == nil { - switch rr := r.(type) { - case refdocker.Named: - if rrName := rr.Name(); rrName != "" { - imageNameBased := path.Base(rrName) - if imageNameBased != "" { - name = imageNameBased + "-" + containerID[:shortIDLength] - } - } - case cid.Cid: - name = "ipfs" + "-" + rr.String()[:shortIDLength] + "-" + containerID[:shortIDLength] - case stringRef: - name = rr.scheme + "-" + rr.s[:shortIDLength] + "-" + containerID[:shortIDLength] - } - } + if decodedCID, err := cid.Decode(rawRef); err == nil { + ir.Protocol = IPFSProtocol + rawRef = decodedCID.String() + ir.Path = rawRef + return ir, nil } - return name + + if dgst, err := digest.Parse(rawRef); err == nil { + ir.Digest = dgst + return ir, nil + } else if dgst, err := digest.Parse("sha256:" + rawRef); err == nil { + ir.Digest = dgst + return ir, nil + } + + var err error + ir.nn, err = reference.ParseNormalizedNamed(rawRef) + if err != nil { + return ir, err + } + if tg, ok := ir.nn.(reference.Tagged); ok { + ir.ExplicitTag = tg.Tag() + } + if tg, ok := ir.nn.(reference.Named); ok { + ir.nn = reference.TagNameOnly(tg) + ir.Domain = reference.Domain(tg) + ir.Path = reference.Path(tg) + } + if tg, ok := ir.nn.(reference.Tagged); ok { + ir.Tag = tg.Tag() + } + if tg, ok := ir.nn.(reference.Digested); ok { + ir.Digest = tg.Digest() + } + + return ir, nil } diff --git a/pkg/referenceutil/referenceutil_test.go b/pkg/referenceutil/referenceutil_test.go index 262a376d9a0..8c066434857 100644 --- a/pkg/referenceutil/referenceutil_test.go +++ b/pkg/referenceutil/referenceutil_test.go @@ -19,17 +19,285 @@ package referenceutil import ( "testing" + "github.com/opencontainers/go-digest" "gotest.tools/v3/assert" ) -func TestSuggestContainerName(t *testing.T) { - const containerID = "16f6d167d4f4743e48affb86e7097222b7992b34a29dab5f8c10cd6a90cdd990" - assert.Equal(t, "alpine-16f6d", SuggestContainerName("alpine", containerID)) - assert.Equal(t, "alpine-16f6d", SuggestContainerName("alpine:3.15", containerID)) - assert.Equal(t, "alpine-16f6d", SuggestContainerName("docker.io/library/alpine:3.15", containerID)) - assert.Equal(t, "alpine-16f6d", SuggestContainerName("docker.io/library/alpine:latest", containerID)) - assert.Equal(t, "ipfs-bafkr-16f6d", SuggestContainerName("bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", containerID)) - assert.Equal(t, "ipfs-bafkr-16f6d", SuggestContainerName("ipfs://bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", containerID)) - assert.Equal(t, "untitled-16f6d", SuggestContainerName("invalid://alpine", containerID)) - assert.Equal(t, "untitled-16f6d", SuggestContainerName("", containerID)) +func TestReferenceUtil(t *testing.T) { + needles := map[string]struct { + Error string + String string + Suggested string + FamiliarName string + FamiliarMatch map[string]bool + Protocol Protocol + Digest digest.Digest + Path string + Domain string + Tag string + ExplicitTag string + }{ + "": { + Error: "invalid reference format", + }, + "∞": { + Error: "invalid reference format", + }, + "abcd:∞": { + Error: "invalid reference format", + }, + "abcd@sha256:∞": { + Error: "invalid reference format", + }, + "abcd@∞": { + Error: "invalid reference format", + }, + "abcd:foo@sha256:∞": { + Error: "invalid reference format", + }, + "abcd:foo@∞": { + Error: "invalid reference format", + }, + "sha256:whatever": { + Error: "", + String: "docker.io/library/sha256:whatever", + Suggested: "sha256-abcde", + FamiliarName: "sha256", + FamiliarMatch: map[string]bool{ + "*a*": true, + "?ha25?": true, + "[s-z]ha25[0-9]": true, + "[^a]ha25[^a-z]": true, + "*6:whatever": true, + "docker.io/library/sha256": false, + }, + Protocol: "", + Digest: "", + Path: "library/sha256", + Domain: "docker.io", + Tag: "whatever", + ExplicitTag: "whatever", + }, + "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50": { + Error: "", + String: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Suggested: "untitled-abcde", + FamiliarName: "", + Protocol: "", + Digest: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Path: "", + Domain: "", + Tag: "", + }, + "4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50": { + Error: "", + String: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Suggested: "untitled-abcde", + FamiliarName: "", + Protocol: "", + Digest: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Path: "", + Domain: "", + Tag: "", + }, + "image_name": { + Error: "", + String: "docker.io/library/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "", + Path: "library/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "", + }, + "library/image_name": { + Error: "", + String: "docker.io/library/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "", + Path: "library/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "", + }, + "something/image_name": { + Error: "", + String: "docker.io/something/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "something/image_name", + Protocol: "", + Digest: "", + Path: "something/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "", + }, + "docker.io/library/image_name": { + Error: "", + String: "docker.io/library/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "", + Path: "library/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "", + }, + "image_name:latest": { + Error: "", + String: "docker.io/library/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "", + Path: "library/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "latest", + }, + "image_name:foo": { + Error: "", + String: "docker.io/library/image_name:foo", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "", + Path: "library/image_name", + Domain: "docker.io", + Tag: "foo", + ExplicitTag: "foo", + }, + "image_name@sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50": { + Error: "", + String: "docker.io/library/image_name@sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Path: "library/image_name", + Domain: "docker.io", + Tag: "", + ExplicitTag: "", + }, + "image_name:latest@sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50": { + Error: "", + String: "docker.io/library/image_name:latest@sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Suggested: "image_name-abcde", + FamiliarName: "image_name", + Protocol: "", + Digest: "sha256:4b826db5f1f14d1db0b560304f189d4b17798ddce2278b7822c9d32313fe3f50", + Path: "library/image_name", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "latest", + }, + "ghcr.io:1234/image_name": { + Error: "", + String: "ghcr.io:1234/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "ghcr.io:1234/image_name", + Protocol: "", + Digest: "", + Path: "image_name", + Domain: "ghcr.io:1234", + Tag: "latest", + ExplicitTag: "", + }, + "ghcr.io/sub_name/image_name": { + Error: "", + String: "ghcr.io/sub_name/image_name:latest", + Suggested: "image_name-abcde", + FamiliarName: "ghcr.io/sub_name/image_name", + Protocol: "", + Digest: "", + Path: "sub_name/image_name", + Domain: "ghcr.io", + Tag: "latest", + ExplicitTag: "", + }, + "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze": { + Error: "", + String: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Suggested: "ipfs-bafkr-abcde", + FamiliarName: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Protocol: "ipfs", + Digest: "", + Path: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Domain: "", + Tag: "", + ExplicitTag: "", + }, + "ipfs://bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze": { + Error: "", + String: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Suggested: "ipfs-bafkr-abcde", + FamiliarName: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Protocol: "ipfs", + Digest: "", + Path: "bafkreicq4dg6nkef5ju422ptedcwfz6kcvpvvhuqeykfrwq5krazf3muze", + Domain: "", + Tag: "", + ExplicitTag: "", + }, + "ipfs://ghcr.io/stargz-containers/alpine:3.13-org": { + Error: "", + String: "ghcr.io/stargz-containers/alpine:3.13-org", + Suggested: "alpine-abcde", + FamiliarName: "ghcr.io/stargz-containers/alpine", + FamiliarMatch: map[string]bool{ + "ghcr.io/stargz-containers/alpine": true, + "*/*/*": true, + "*/*/*:3.13-org": true, + }, + Protocol: "ipfs", + Digest: "", + Path: "stargz-containers/alpine", + Domain: "ghcr.io", + Tag: "3.13-org", + ExplicitTag: "3.13-org", + }, + "ipfs://alpine": { + Error: "", + String: "docker.io/library/alpine:latest", + Suggested: "alpine-abcde", + FamiliarName: "alpine", + Protocol: "ipfs", + Digest: "", + Path: "library/alpine", + Domain: "docker.io", + Tag: "latest", + ExplicitTag: "", + }, + "oci-archive:///tmp/build/saved-image.tar": { + Error: "image must be loaded from archive before parsing image reference", + }, + } + + for k, v := range needles { + parsed, err := Parse(k) + if v.Error != "" || err != nil { + assert.Error(t, err, v.Error) + continue + } + assert.Equal(t, parsed.String(), v.String, k) + assert.Equal(t, parsed.SuggestContainerName("abcdefghij"), v.Suggested, k) + assert.Equal(t, parsed.FamiliarName(), v.FamiliarName, k) + for needle, result := range v.FamiliarMatch { + res, err := parsed.FamiliarMatch(needle) + assert.NilError(t, err) + assert.Equal(t, res, result, k) + } + + assert.Equal(t, parsed.Protocol, v.Protocol, k) + assert.Equal(t, parsed.Digest, v.Digest, k) + assert.Equal(t, parsed.Path, v.Path, k) + assert.Equal(t, parsed.Domain, v.Domain, k) + assert.Equal(t, parsed.Tag, v.Tag, k) + assert.Equal(t, parsed.ExplicitTag, v.ExplicitTag, k) + } } diff --git a/pkg/resolvconf/resolvconf.go b/pkg/resolvconf/resolvconf.go index e776c9f8e40..79bec3ecd9e 100644 --- a/pkg/resolvconf/resolvconf.go +++ b/pkg/resolvconf/resolvconf.go @@ -35,7 +35,7 @@ import ( "strings" "sync" - "github.com/sirupsen/logrus" + "github.com/containerd/log" ) const ( @@ -78,7 +78,7 @@ func Path() string { ns := GetNameservers(candidateResolvConf, IP) if len(ns) == 1 && ns[0] == "127.0.0.53" { pathAfterSystemdDetection = alternatePath - logrus.Debugf("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath) + log.L.Debugf("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath) } }) return pathAfterSystemdDetection @@ -190,10 +190,10 @@ func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) { // if the resulting resolvConf has no more nameservers defined, add appropriate // default DNS servers for IPv4 and (optionally) IPv6 if len(GetNameservers(cleanedResolvConf, IP)) == 0 { - logrus.Infof("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers: %v", defaultIPv4Dns) + log.L.Infof("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers: %v", defaultIPv4Dns) dns := defaultIPv4Dns if ipv6Enabled { - logrus.Infof("IPv6 enabled; Adding default IPv6 external servers: %v", defaultIPv6Dns) + log.L.Infof("IPv6 enabled; Adding default IPv6 external servers: %v", defaultIPv6Dns) dns = append(dns, defaultIPv6Dns...) } cleanedResolvConf = append(cleanedResolvConf, []byte("\n"+strings.Join(dns, "\n"))...) @@ -317,7 +317,16 @@ func Build(path string, dns, dnsSearch, dnsOptions []string) (*File, error) { return nil, err } - return &File{Content: content.Bytes(), Hash: hash}, os.WriteFile(path, content.Bytes(), 0644) + err = os.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 + // 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. + return &File{Content: content.Bytes(), Hash: hash}, os.Chmod(path, 0o644) } func hashData(src io.Reader) (string, error) { diff --git a/pkg/rootlessutil/child_linux.go b/pkg/rootlessutil/child_linux.go index 1f6d82bc049..d318463846a 100644 --- a/pkg/rootlessutil/child_linux.go +++ b/pkg/rootlessutil/child_linux.go @@ -19,7 +19,7 @@ package rootlessutil import ( "os" - "github.com/containerd/containerd/pkg/userns" + "github.com/moby/sys/userns" ) func IsRootlessChild() bool { diff --git a/pkg/rootlessutil/parent_linux.go b/pkg/rootlessutil/parent_linux.go index ab9f627af80..d90b9b77dee 100644 --- a/pkg/rootlessutil/parent_linux.go +++ b/pkg/rootlessutil/parent_linux.go @@ -26,7 +26,7 @@ import ( "strings" "syscall" - "github.com/sirupsen/logrus" + "github.com/containerd/log" ) func IsRootlessParent() bool { @@ -68,7 +68,7 @@ func ParentMain(hostGatewayIP string) error { return errors.New("should not be called when !IsRootlessParent()") } stateDir, err := RootlessKitStateDir() - logrus.Debugf("stateDir: %s", stateDir) + log.L.Debugf("stateDir: %s", stateDir) if err != nil { return fmt.Errorf("rootless containerd not running? (hint: use `containerd-rootless-setuptool.sh install` to start rootless containerd): %w", err) } @@ -77,10 +77,12 @@ func ParentMain(hostGatewayIP string) error { return err } - wd, err := os.Getwd() + detachedNetNSPath, err := detachedNetNS(stateDir) if err != nil { return err } + detachNetNSMode := detachedNetNSPath != "" + log.L.Debugf("RootlessKit detach-netns mode: %v", detachNetNSMode) // FIXME: remove dependency on `nsenter` binary arg0, err := exec.LookPath("nsenter") @@ -89,15 +91,30 @@ func ParentMain(hostGatewayIP string) error { } // args are compatible with both util-linux nsenter and busybox nsenter args := []string{ - "-r/", // root dir (busybox nsenter wants this to be explicitly specified), - "-w" + wd, // work dir - "--preserve-credentials", - "-m", "-n", "-U", + "-r/", // root dir (busybox nsenter wants this to be explicitly specified), + } + + // Only append wd if we do have a working dir + // - https://github.com/rootless-containers/usernetes/pull/327 + // - https://github.com/containerd/nerdctl/issues/3328 + wd, err := os.Getwd() + if err != nil { + log.L.WithError(err).Warn("unable to determine working directory") + } else { + args = append(args, "-w"+wd) + os.Setenv("PWD", wd) + } + + args = append(args, "--preserve-credentials", + "-m", "-U", "-t", strconv.Itoa(childPid), "-F", // no fork + ) + if !detachNetNSMode { + args = append(args, "-n") } args = append(args, os.Args...) - logrus.Debugf("rootless parent main: executing %q with %v", arg0, args) + log.L.Debugf("rootless parent main: executing %q with %v", arg0, args) // Env vars corresponds to RootlessKit spec: // https://github.com/rootless-containers/rootlesskit/tree/v0.13.1#environment-variables diff --git a/pkg/rootlessutil/port_linux.go b/pkg/rootlessutil/port_linux.go index 108ef6b4602..dddf37a6f98 100644 --- a/pkg/rootlessutil/port_linux.go +++ b/pkg/rootlessutil/port_linux.go @@ -20,10 +20,11 @@ import ( "context" "net" - "github.com/containerd/containerd/errdefs" - gocni "github.com/containerd/go-cni" - "github.com/rootless-containers/rootlesskit/pkg/api/client" - "github.com/rootless-containers/rootlesskit/pkg/port" + "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" + "github.com/rootless-containers/rootlesskit/v2/pkg/port" + + "github.com/containerd/errdefs" + "github.com/containerd/go-cni" ) func NewRootlessCNIPortManager(client client.Client) (*RootlessCNIPortManager, error) { @@ -40,7 +41,7 @@ type RootlessCNIPortManager struct { client.Client } -func (rlcpm *RootlessCNIPortManager) ExposePort(ctx context.Context, cpm gocni.PortMapping) error { +func (rlcpm *RootlessCNIPortManager) ExposePort(ctx context.Context, cpm cni.PortMapping) error { // NOTE: When `nerdctl run -p 8080:80` is being launched, cpm.HostPort is set to 8080 and cpm.ContainerPort is set to 80. // We want to forward the port 8080 of the parent namespace into the port 8080 of the child namespace (which is the "host" // from the point of view of CNI). So we do NOT set sp.ChildPort to cpm.ContainerPort here. @@ -54,7 +55,7 @@ func (rlcpm *RootlessCNIPortManager) ExposePort(ctx context.Context, cpm gocni.P return err } -func (rlcpm *RootlessCNIPortManager) UnexposePort(ctx context.Context, cpm gocni.PortMapping) error { +func (rlcpm *RootlessCNIPortManager) UnexposePort(ctx context.Context, cpm cni.PortMapping) error { pm := rlcpm.Client.PortManager() ports, err := pm.ListPorts(ctx) if err != nil { diff --git a/pkg/rootlessutil/rootlessutil_linux.go b/pkg/rootlessutil/rootlessutil_linux.go index 76d77cea860..bb5349f255f 100644 --- a/pkg/rootlessutil/rootlessutil_linux.go +++ b/pkg/rootlessutil/rootlessutil_linux.go @@ -17,12 +17,14 @@ package rootlessutil import ( + "errors" "fmt" "os" "path/filepath" "strconv" - "github.com/rootless-containers/rootlesskit/pkg/api/client" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" ) func IsRootless() bool { @@ -67,3 +69,53 @@ func NewRootlessKitClient() (client.Client, error) { apiSock := filepath.Join(stateDir, "api.sock") return client.New(apiSock) } + +// RootlessContainredSockAddress returns sock address of rootless containerd based on https://github.com/containerd/nerdctl/blob/main/docs/faq.md#containerd-socket-address +func RootlessContainredSockAddress() (string, error) { + stateDir, err := RootlessKitStateDir() + if err != nil { + return "", err + } + childPid, err := RootlessKitChildPid(stateDir) + if err != nil { + return "", err + } + return filepath.Join(fmt.Sprintf("/proc/%d/root/run/containerd/containerd.sock", childPid)), nil +} + +// DetachedNetNS returns non-empty netns path if RootlessKit is running with --detach-netns mode. +// Otherwise returns "" without an error. +func DetachedNetNS() (string, error) { + if !IsRootless() { + return "", nil + } + stateDir, err := RootlessKitStateDir() + if err != nil { + return "", err + } + return detachedNetNS(stateDir) +} + +func detachedNetNS(stateDir string) (string, error) { + p := filepath.Join(stateDir, "netns") + if _, err := os.Stat(p); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + return p, nil +} + +// WithDetachedNetNSIfAny executes fn in [DetachedNetNS] if RootlessKit is running with --detach-netns mode. +// Otherwise it just executes fn in the current netns. +func WithDetachedNetNSIfAny(fn func() error) error { + netns, err := DetachedNetNS() + if err != nil { + return err + } + if netns == "" { + return fn() + } + return ns.WithNetNSPath(netns, func(_ ns.NetNS) error { return fn() }) +} diff --git a/pkg/rootlessutil/rootlessutil_other.go b/pkg/rootlessutil/rootlessutil_other.go index 7f9abe2a1cc..4ebd5c1d832 100644 --- a/pkg/rootlessutil/rootlessutil_other.go +++ b/pkg/rootlessutil/rootlessutil_other.go @@ -25,7 +25,7 @@ package rootlessutil import ( "fmt" - "github.com/rootless-containers/rootlesskit/pkg/api/client" + "github.com/rootless-containers/rootlesskit/v2/pkg/api/client" ) // Always returns false on non-Linux platforms. @@ -62,3 +62,15 @@ func NewRootlessKitClient() (client.Client, error) { func ParentMain(hostGatewayIP string) error { return fmt.Errorf("cannot use RootlessKit on main entry point on non-Linux hosts") } + +func RootlessContainredSockAddress() (string, error) { + return "", fmt.Errorf("cannot inspect RootlessKit state on non-Linux hosts") +} + +func DetachedNetNS() (string, error) { + return "", nil +} + +func WithDetachedNetNSIfAny(fn func() error) error { + return fn() +} diff --git a/pkg/signalutil/signals.go b/pkg/signalutil/signals.go index 69126cfb368..608cd575860 100644 --- a/pkg/signalutil/signals.go +++ b/pkg/signalutil/signals.go @@ -17,39 +17,39 @@ package signalutil import ( - gocontext "context" + "context" "os" "os/signal" "syscall" - "github.com/containerd/containerd" - "github.com/containerd/containerd/errdefs" - "github.com/sirupsen/logrus" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/errdefs" + "github.com/containerd/log" ) // killer is from https://github.com/containerd/containerd/blob/v1.7.0-rc.2/cmd/ctr/commands/signals.go#L30-L32 type killer interface { - Kill(gocontext.Context, syscall.Signal, ...containerd.KillOpts) error + Kill(context.Context, syscall.Signal, ...containerd.KillOpts) error } // ForwardAllSignals forwards signals. // From https://github.com/containerd/containerd/blob/v1.7.0-rc.2/cmd/ctr/commands/signals.go#L34-L55 -func ForwardAllSignals(ctx gocontext.Context, task killer) chan os.Signal { +func ForwardAllSignals(ctx context.Context, task killer) chan os.Signal { sigc := make(chan os.Signal, 128) signal.Notify(sigc) go func() { for s := range sigc { if canIgnoreSignal(s) { - logrus.Debugf("Ignoring signal %s", s) + log.G(ctx).Debugf("Ignoring signal %s", s) continue } - logrus.Debug("forwarding signal ", s) + log.G(ctx).Debug("forwarding signal ", s) if err := task.Kill(ctx, s.(syscall.Signal)); err != nil { if errdefs.IsNotFound(err) { - logrus.WithError(err).Debugf("Not forwarding signal %s", s) + log.G(ctx).WithError(err).Debugf("Not forwarding signal %s", s) return } - logrus.WithError(err).Errorf("forward signal %s", s) + log.G(ctx).WithError(err).Errorf("forward signal %s", s) } } }() diff --git a/pkg/signalutil/signals_notlinux.go b/pkg/signalutil/signals_other.go similarity index 100% rename from pkg/signalutil/signals_notlinux.go rename to pkg/signalutil/signals_other.go diff --git a/pkg/signutil/cosignutil.go b/pkg/signutil/cosignutil.go index 80cce2a94d6..766ec9824e2 100644 --- a/pkg/signutil/cosignutil.go +++ b/pkg/signutil/cosignutil.go @@ -24,16 +24,17 @@ import ( "os/exec" "strings" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil" ) // SignCosign signs an image(`rawRef`) using a cosign private key (`keyRef`) func SignCosign(rawRef string, keyRef string) error { cosignExecutable, err := exec.LookPath("cosign") if err != nil { - logrus.WithError(err).Error("cosign executable not found in path $PATH") - logrus.Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation") + log.L.WithError(err).Error("cosign executable not found in path $PATH") + log.L.Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation") return err } @@ -50,18 +51,14 @@ func SignCosign(rawRef string, keyRef string) error { cosignCmd.Args = append(cosignCmd.Args, "--yes") cosignCmd.Args = append(cosignCmd.Args, rawRef) - logrus.Debugf("running %s %v", cosignExecutable, cosignCmd.Args) + log.L.Debugf("running %s %v", cosignExecutable, cosignCmd.Args) err = processCosignIO(cosignCmd) if err != nil { return err } - if err := cosignCmd.Wait(); err != nil { - return err - } - - return nil + return cosignCmd.Wait() } // VerifyCosign verifies an image(`rawRef`) with a cosign public key(`keyRef`) @@ -71,7 +68,7 @@ func VerifyCosign(ctx context.Context, rawRef string, keyRef string, hostsDirs [ certIdentity string, certIdentityRegexp string, certOidcIssuer string, certOidcIssuerRegexp string) (string, error) { digest, err := imgutil.ResolveDigest(ctx, rawRef, false, hostsDirs) if err != nil { - logrus.WithError(err).Errorf("unable to resolve digest for an image %s: %v", rawRef, err) + log.G(ctx).WithError(err).Errorf("unable to resolve digest for an image %s: %v", rawRef, err) return rawRef, err } ref := rawRef @@ -79,12 +76,12 @@ func VerifyCosign(ctx context.Context, rawRef string, keyRef string, hostsDirs [ ref += "@" + digest } - logrus.Debugf("verifying image: %s", ref) + log.G(ctx).Debugf("verifying image: %s", ref) cosignExecutable, err := exec.LookPath("cosign") if err != nil { - logrus.WithError(err).Error("cosign executable not found in path $PATH") - logrus.Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation") + log.G(ctx).WithError(err).Error("cosign executable not found in path $PATH") + log.G(ctx).Info("you might consider installing cosign from: https://docs.sigstore.dev/cosign/installation") return ref, err } @@ -118,7 +115,7 @@ func VerifyCosign(ctx context.Context, rawRef string, keyRef string, hostsDirs [ cosignCmd.Args = append(cosignCmd.Args, ref) - logrus.Debugf("running %s %v", cosignExecutable, cosignCmd.Args) + log.G(ctx).Debugf("running %s %v", cosignExecutable, cosignCmd.Args) err = processCosignIO(cosignCmd) if err != nil { @@ -134,11 +131,11 @@ func VerifyCosign(ctx context.Context, rawRef string, keyRef string, hostsDirs [ func processCosignIO(cosignCmd *exec.Cmd) error { stdout, err := cosignCmd.StdoutPipe() if err != nil { - logrus.Warn("cosign: " + err.Error()) + log.L.Warn("cosign: " + err.Error()) } stderr, err := cosignCmd.StderrPipe() if err != nil { - logrus.Warn("cosign: " + err.Error()) + log.L.Warn("cosign: " + err.Error()) } if err := cosignCmd.Start(); err != nil { // only return err if it's critical (cosign start failed.) @@ -147,18 +144,18 @@ func processCosignIO(cosignCmd *exec.Cmd) error { scanner := bufio.NewScanner(stdout) for scanner.Scan() { - logrus.Info("cosign: " + scanner.Text()) + log.L.Info("cosign: " + scanner.Text()) } if err := scanner.Err(); err != nil { - logrus.Warn("cosign: " + err.Error()) + log.L.Warn("cosign: " + err.Error()) } errScanner := bufio.NewScanner(stderr) for errScanner.Scan() { - logrus.Info("cosign: " + errScanner.Text()) + log.L.Info("cosign: " + errScanner.Text()) } if err := errScanner.Err(); err != nil { - logrus.Warn("cosign: " + err.Error()) + log.L.Warn("cosign: " + err.Error()) } return nil diff --git a/pkg/signutil/notationutil.go b/pkg/signutil/notationutil.go index 3af6b7b8593..0e5a69a0b2b 100644 --- a/pkg/signutil/notationutil.go +++ b/pkg/signutil/notationutil.go @@ -23,16 +23,17 @@ import ( "os/exec" "strings" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/imgutil" ) // SignNotation signs an image(`rawRef`) using a notation key name (`keyNameRef`) func SignNotation(rawRef string, keyNameRef string) error { notationExecutable, err := exec.LookPath("notation") if err != nil { - logrus.WithError(err).Error("notation executable not found in path $PATH") - logrus.Info("you might consider installing notation from: https://notaryproject.dev/docs/installation/cli/") + log.L.WithError(err).Error("notation executable not found in path $PATH") + log.L.Info("you might consider installing notation from: https://notaryproject.dev/docs/installation/cli/") return err } @@ -46,18 +47,14 @@ func SignNotation(rawRef string, keyNameRef string) error { notationCmd.Args = append(notationCmd.Args, rawRef) - logrus.Debugf("running %s %v", notationExecutable, notationCmd.Args) + log.L.Debugf("running %s %v", notationExecutable, notationCmd.Args) err = processNotationIO(notationCmd) if err != nil { return err } - if err := notationCmd.Wait(); err != nil { - return err - } - - return nil + return notationCmd.Wait() } // VerifyNotation verifies an image(`rawRef`) with the pre-configured notation trust policy @@ -65,7 +62,7 @@ func SignNotation(rawRef string, keyNameRef string) error { func VerifyNotation(ctx context.Context, rawRef string, hostsDirs []string) (string, error) { digest, err := imgutil.ResolveDigest(ctx, rawRef, false, hostsDirs) if err != nil { - logrus.WithError(err).Errorf("unable to resolve digest for an image %s: %v", rawRef, err) + log.G(ctx).WithError(err).Errorf("unable to resolve digest for an image %s: %v", rawRef, err) return rawRef, err } ref := rawRef @@ -73,12 +70,12 @@ func VerifyNotation(ctx context.Context, rawRef string, hostsDirs []string) (str ref += "@" + digest } - logrus.Debugf("verifying image: %s", ref) + log.G(ctx).Debugf("verifying image: %s", ref) notationExecutable, err := exec.LookPath("notation") if err != nil { - logrus.WithError(err).Error("notation executable not found in path $PATH") - logrus.Info("you might consider installing notation from: https://notaryproject.dev/docs/installation/cli/") + log.G(ctx).WithError(err).Error("notation executable not found in path $PATH") + log.G(ctx).Info("you might consider installing notation from: https://notaryproject.dev/docs/installation/cli/") return ref, err } @@ -87,7 +84,7 @@ func VerifyNotation(ctx context.Context, rawRef string, hostsDirs []string) (str notationCmd.Args = append(notationCmd.Args, ref) - logrus.Debugf("running %s %v", notationExecutable, notationCmd.Args) + log.G(ctx).Debugf("running %s %v", notationExecutable, notationCmd.Args) err = processNotationIO(notationCmd) if err != nil { @@ -103,11 +100,11 @@ func VerifyNotation(ctx context.Context, rawRef string, hostsDirs []string) (str func processNotationIO(notationCmd *exec.Cmd) error { stdout, err := notationCmd.StdoutPipe() if err != nil { - logrus.Warn("notation: " + err.Error()) + log.L.Warn("notation: " + err.Error()) } stderr, err := notationCmd.StderrPipe() if err != nil { - logrus.Warn("notation: " + err.Error()) + log.L.Warn("notation: " + err.Error()) } if err := notationCmd.Start(); err != nil { // only return err if it's critical (notation start failed.) @@ -116,18 +113,18 @@ func processNotationIO(notationCmd *exec.Cmd) error { scanner := bufio.NewScanner(stdout) for scanner.Scan() { - logrus.Info("notation: " + scanner.Text()) + log.L.Info("notation: " + scanner.Text()) } if err := scanner.Err(); err != nil { - logrus.Warn("notation: " + err.Error()) + log.L.Warn("notation: " + err.Error()) } errScanner := bufio.NewScanner(stderr) for errScanner.Scan() { - logrus.Info("notation: " + errScanner.Text()) + log.L.Info("notation: " + errScanner.Text()) } if err := errScanner.Err(); err != nil { - logrus.Warn("notation: " + err.Error()) + log.L.Warn("notation: " + err.Error()) } return nil diff --git a/pkg/signutil/signutil.go b/pkg/signutil/signutil.go index 716816e1fc2..de370e0d369 100644 --- a/pkg/signutil/signutil.go +++ b/pkg/signutil/signutil.go @@ -20,8 +20,9 @@ import ( "context" "fmt" - "github.com/containerd/nerdctl/pkg/api/types" - "github.com/sirupsen/logrus" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) // Sign signs an image using a signer and options provided in options. @@ -44,7 +45,7 @@ func Sign(rawRef string, experimental bool, options types.ImageSignOptions) erro return err } case "", "none": - logrus.Debugf("signing process skipped") + log.L.Debugf("signing process skipped") default: return fmt.Errorf("no signers found: %s", options.Provider) } @@ -72,7 +73,7 @@ func Verify(ctx context.Context, rawRef string, hostsDirs []string, experimental } case "", "none": ref = rawRef - logrus.Debugf("verifying process skipped") + log.G(ctx).Debugf("verifying process skipped") default: return "", fmt.Errorf("no verifiers found: %s", options.Provider) } diff --git a/pkg/snapshotterutil/socisource.go b/pkg/snapshotterutil/socisource.go new file mode 100644 index 00000000000..7bd0184085c --- /dev/null +++ b/pkg/snapshotterutil/socisource.go @@ -0,0 +1,111 @@ +/* + 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. +*/ + +/* + Copyright The Soci Snapshotter 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. +*/ + +// Taken from https://github.com/awslabs/soci-snapshotter/blob/237fc956b8366e49927c84fcfee9a2defbb8f53c/fs/source/source.go +// to avoid taking dependency, as maintainers do not wish to upgrade to containerd v2 yet. + +package snapshotterutil + +import ( + "context" + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/labels" + ctdsnapshotters "github.com/containerd/containerd/v2/pkg/snapshotters" +) + +const ( + // TargetSizeLabel is a label which contains layer size. + TargetSizeLabel = "containerd.io/snapshot/remote/soci.size" + + // targetImageLayersSizeLabel is a label which contains layer sizes contained in + // the target image. + targetImageLayersSizeLabel = "containerd.io/snapshot/remote/image.layers.size" + + // TargetSociIndexDigestLabel is a label which contains the digest of the soci index. + TargetSociIndexDigestLabel = "containerd.io/snapshot/remote/soci.index.digest" +) + +// SociAppendDefaultLabelsHandlerWrapper makes a handler which appends image's basic +// information to each layer descriptor as annotations during unpack. These +// annotations will be passed to this remote snapshotter as labels and used to +// construct source information. +func SociAppendDefaultLabelsHandlerWrapper(indexDigest string, wrapper func(images.Handler) images.Handler) func(f images.Handler) images.Handler { + return func(f images.Handler) images.Handler { + return images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + children, err := wrapper(f).Handle(ctx, desc) + if err != nil { + return nil, err + } + switch desc.MediaType { + case ocispec.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest: + for i := range children { + c := &children[i] + if images.IsLayerType(c.MediaType) { + if c.Annotations == nil { + c.Annotations = make(map[string]string) + } + + c.Annotations[TargetSizeLabel] = fmt.Sprintf("%d", c.Size) + c.Annotations[TargetSociIndexDigestLabel] = indexDigest + + remainingLayerDigestsCount := len(strings.Split(c.Annotations[ctdsnapshotters.TargetImageLayersLabel], ",")) + + var layerSizes string + /* + We must ensure that the counts of layer sizes and layer digests are equal. + We will limit the # of neighboring label sizes to equal the # of neighboring + layer digests for any given layer. + */ + for _, l := range children[i : i+remainingLayerDigestsCount] { + if images.IsLayerType(l.MediaType) { + ls := fmt.Sprintf("%d,", l.Size) + // This avoids the label hits the size limitation. + // Skipping layers is allowed here and only affects performance. + if err := labels.Validate(targetImageLayersSizeLabel, layerSizes+ls); err != nil { + break + } + layerSizes += ls + } + } + c.Annotations[targetImageLayersSizeLabel] = strings.TrimSuffix(layerSizes, ",") + } + } + } + return children, nil + }) + } +} diff --git a/pkg/snapshotterutil/sociutil.go b/pkg/snapshotterutil/sociutil.go new file mode 100644 index 00000000000..a2148de027c --- /dev/null +++ b/pkg/snapshotterutil/sociutil.go @@ -0,0 +1,174 @@ +/* + 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 snapshotterutil + +import ( + "bufio" + "os" + "os/exec" + "strconv" + "strings" + + "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 { + 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 + } + + 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, "create") + + if allPlatform { + sociCmd.Args = append(sociCmd.Args, "--all-platforms") + } + if len(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 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)) + } + // --timeout, --debug, --content-store + sociCmd.Args = append(sociCmd.Args, rawRef) + + log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args) + + err = processSociIO(sociCmd) + if err != nil { + return err + } + + return sociCmd.Wait() +} + +// PushSoci pushes a SOCI index(`rawRef`) +// `hostsDirs` are used to resolve image `rawRef` +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") + 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") + + if allPlatform { + sociCmd.Args = append(sociCmd.Args, "--all-platforms") + } + if len(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 platforms { + sociCmd.Args = append(sociCmd.Args, "--platform", p) + } + } + if gOpts.InsecureRegistry { + sociCmd.Args = append(sociCmd.Args, "--skip-verify") + sociCmd.Args = append(sociCmd.Args, "--plain-http") + } + if len(gOpts.HostsDir) > 0 { + sociCmd.Args = append(sociCmd.Args, "--hosts-dir") + sociCmd.Args = append(sociCmd.Args, strings.Join(gOpts.HostsDir, ",")) + } + sociCmd.Args = append(sociCmd.Args, rawRef) + + log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args) + + err = processSociIO(sociCmd) + if err != nil { + return err + } + return sociCmd.Wait() +} + +func processSociIO(sociCmd *exec.Cmd) error { + stdout, err := sociCmd.StdoutPipe() + if err != nil { + log.L.Warn("soci: " + err.Error()) + } + stderr, err := sociCmd.StderrPipe() + if err != nil { + log.L.Warn("soci: " + err.Error()) + } + if err := sociCmd.Start(); err != nil { + // only return err if it's critical (soci command failed to start.) + return err + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + log.L.Info("soci: " + scanner.Text()) + } + if err := scanner.Err(); err != nil { + log.L.Warn("soci: " + err.Error()) + } + + errScanner := bufio.NewScanner(stderr) + for errScanner.Scan() { + log.L.Info("soci: " + errScanner.Text()) + } + if err := errScanner.Err(); err != nil { + log.L.Warn("soci: " + err.Error()) + } + + return nil +} diff --git a/pkg/statsutil/stats.go b/pkg/statsutil/stats.go index 41d86f667eb..d732e1d837d 100644 --- a/pkg/statsutil/stats.go +++ b/pkg/statsutil/stats.go @@ -18,6 +18,8 @@ package statsutil import ( "fmt" + "strconv" + "strings" "sync" "time" @@ -26,7 +28,6 @@ import ( // StatsEntry represents the statistics data collected from a container type StatsEntry struct { - Container string Name string ID string CPUPercentage float64 @@ -68,15 +69,14 @@ type ContainerStats struct { } // NewStats is from https://github.com/docker/cli/blob/3fb4fb83dfb5db0c0753a8316f21aea54dab32c5/cli/command/container/formatter_stats.go#L113-L116 -func NewStats(container string) *Stats { - return &Stats{StatsEntry: StatsEntry{Container: container}} +func NewStats(containerID string) *Stats { + return &Stats{StatsEntry: StatsEntry{ID: containerID}} } // SetStatistics is from https://github.com/docker/cli/blob/3fb4fb83dfb5db0c0753a8316f21aea54dab32c5/cli/command/container/formatter_stats.go#L87-L93 func (cs *Stats) SetStatistics(s StatsEntry) { cs.mutex.Lock() defer cs.mutex.Unlock() - s.Container = cs.Container cs.StatsEntry = s } @@ -121,19 +121,10 @@ func (cs *Stats) SetError(err error) { } } -func calculateMemPercent(limit float64, usedNo float64) float64 { - // Limit will never be 0 unless the container is not running and we haven't - // got any data from cgroup - if limit != 0 { - return usedNo / limit * 100.0 - } - return 0 -} - // Rendering a FormattedStatsEntry from StatsEntry func RenderEntry(in *StatsEntry, noTrunc bool) FormattedStatsEntry { return FormattedStatsEntry{ - Name: in.EntryName(), + Name: in.EntryName(noTrunc), ID: in.EntryID(noTrunc), CPUPerc: in.CPUPerc(), MemUsage: in.MemUsage(), @@ -147,10 +138,18 @@ func RenderEntry(in *StatsEntry, noTrunc bool) FormattedStatsEntry { /* a set of functions to format container stats */ -func (s *StatsEntry) EntryName() string { +func (s *StatsEntry) EntryName(noTrunc bool) string { if len(s.Name) > 1 { - if len(s.Name) > 12 { - return s.Name[:12] + if !noTrunc { + var truncLen int + if strings.HasPrefix(s.Name, "k8s://") { + truncLen = 24 + } else { + truncLen = 12 + } + if len(s.Name) > truncLen { + return s.Name[:truncLen] + } } return s.Name } @@ -205,5 +204,5 @@ func (s *StatsEntry) PIDs() string { if s.IsInvalid { return "--" } - return fmt.Sprintf("%d", s.PidsCurrent) + return strconv.FormatUint(s.PidsCurrent, 10) } diff --git a/pkg/statsutil/stats_linux.go b/pkg/statsutil/stats_linux.go index 147547de931..4f1f53bc828 100644 --- a/pkg/statsutil/stats_linux.go +++ b/pkg/statsutil/stats_linux.go @@ -17,19 +17,32 @@ package statsutil import ( + "bufio" + "os" + "strconv" + "strings" "time" + "github.com/vishvananda/netlink" + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" v2 "github.com/containerd/cgroups/v3/cgroup2/stats" - "github.com/vishvananda/netlink" ) -func SetCgroupStatsFields(previousStats *ContainerStats, data *v1.Metrics, links []netlink.Link) (StatsEntry, error) { +func calculateMemPercent(limit float64, usedNo float64) float64 { + // Limit will never be 0 unless the container is not running and we haven't + // got any data from cgroup + if limit != 0 { + return usedNo / limit * 100.0 + } + return 0 +} +func SetCgroupStatsFields(previousStats *ContainerStats, data *v1.Metrics, links []netlink.Link) (StatsEntry, error) { cpuPercent := calculateCgroupCPUPercent(previousStats, data) blkRead, blkWrite := calculateCgroupBlockIO(data) mem := calculateCgroupMemUsage(data) - memLimit := float64(data.Memory.Usage.Limit) + memLimit := getCgroupMemLimit(float64(data.Memory.Usage.Limit)) memPercent := calculateMemPercent(memLimit, mem) pidsStatsCurrent := data.Pids.Current netRx, netTx := calculateCgroupNetwork(links) @@ -49,11 +62,10 @@ func SetCgroupStatsFields(previousStats *ContainerStats, data *v1.Metrics, links } func SetCgroup2StatsFields(previousStats *ContainerStats, metrics *v2.Metrics, links []netlink.Link) (StatsEntry, error) { - cpuPercent := calculateCgroup2CPUPercent(previousStats, metrics) blkRead, blkWrite := calculateCgroup2IO(metrics) mem := calculateCgroup2MemUsage(metrics) - memLimit := float64(metrics.Memory.UsageLimit) + memLimit := getCgroupMemLimit(float64(metrics.Memory.UsageLimit)) memPercent := calculateMemPercent(memLimit, mem) pidsStatsCurrent := metrics.Pids.Current netRx, netTx := calculateCgroupNetwork(links) @@ -72,6 +84,36 @@ func SetCgroup2StatsFields(previousStats *ContainerStats, metrics *v2.Metrics, l } +func getCgroupMemLimit(memLimit float64) float64 { + if memLimit == float64(^uint64(0)) { + return getHostMemLimit() + } + return memLimit +} + +func getHostMemLimit() float64 { + file, err := os.Open("/proc/meminfo") + if err != nil { + return float64(^uint64(0)) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "MemTotal:") { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 2 { + memKb, err := strconv.ParseUint(fields[1], 10, 64) + if err == nil { + return float64(memKb * 1024) // kB to bytes + } + } + break + } + } + return float64(^uint64(0)) +} + func calculateCgroupCPUPercent(previousStats *ContainerStats, metrics *v1.Metrics) float64 { var ( cpuPercent = 0.0 diff --git a/pkg/store/filestore.go b/pkg/store/filestore.go new file mode 100644 index 00000000000..312155230fa --- /dev/null +++ b/pkg/store/filestore.go @@ -0,0 +1,384 @@ +/* + 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 store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/containerd/nerdctl/v2/pkg/lockutil" +) + +// TODO: implement a read-lock in lockutil, in addition to the current exclusive write-lock +// This might improve performance in case of (mostly read) massively parallel concurrent scenarios + +const ( + // Default filesystem permissions to use when creating dir or files + defaultFilePerm = 0o600 + defaultDirPerm = 0o700 +) + +// New returns a filesystem based Store implementation that satisfies both Manager and Locker +// Note that atomicity is "guaranteed" by `os.Rename`, which arguably is not *always* atomic. +// In particular, operating-system crashes may break that promise, and windows behavior is probably questionable. +// That being said, this is still a much better solution than writing directly to the destination file. +func New(rootPath string, dirPerm os.FileMode, filePerm os.FileMode) (Store, error) { + if rootPath == "" { + return nil, errors.Join(ErrInvalidArgument, fmt.Errorf("FileStore rootPath cannot be empty")) + } + + if dirPerm == 0 { + dirPerm = defaultDirPerm + } + + if filePerm == 0 { + filePerm = defaultFilePerm + } + + if err := os.MkdirAll(rootPath, dirPerm); err != nil { + return nil, errors.Join(ErrSystemFailure, err) + } + + return &fileStore{ + dir: rootPath, + dirPerm: dirPerm, + filePerm: filePerm, + }, nil +} + +type fileStore struct { + mutex sync.RWMutex + dir string + locked *os.File + dirPerm os.FileMode + filePerm os.FileMode +} + +func (vs *fileStore) Lock() error { + vs.mutex.Lock() + + dirFile, err := lockutil.Lock(vs.dir) + if err != nil { + return errors.Join(ErrLockFailure, err) + } + + vs.locked = dirFile + + return nil +} + +func (vs *fileStore) Release() error { + if vs.locked == nil { + return errors.Join(ErrFaultyImplementation, fmt.Errorf("cannot unlock already unlocked volume store %q", vs.dir)) + } + + defer vs.mutex.Unlock() + + defer func() { + vs.locked = nil + }() + + if err := lockutil.Unlock(vs.locked); err != nil { + return errors.Join(ErrLockFailure, err) + } + + return nil +} + +func (vs *fileStore) WithLock(fun func() error) (err error) { + if err = vs.Lock(); err != nil { + return err + } + + defer func() { + err = errors.Join(vs.Release(), err) + }() + + return fun() +} + +func (vs *fileStore) Get(key ...string) ([]byte, error) { + if vs.locked == nil { + return nil, errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + if err := validateAllPathComponents(key...); err != nil { + return nil, err + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + st, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(ErrNotFound, fmt.Errorf("%q does not exist", filepath.Join(key...))) + } + + return nil, errors.Join(ErrSystemFailure, err) + } + + if st.IsDir() { + 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...)...)) + if err != nil { + return nil, errors.Join(ErrSystemFailure, err) + } + + return content, nil +} + +func (vs *fileStore) Exists(key ...string) (bool, error) { + if err := validateAllPathComponents(key...); err != nil { + return false, err + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + _, err := os.Stat(filepath.Join(path)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, errors.Join(ErrSystemFailure, err) + } + + return true, nil +} + +func (vs *fileStore) Set(data []byte, key ...string) error { + if vs.locked == nil { + return errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + if err := validateAllPathComponents(key...); err != nil { + return err + } + + fileName := key[len(key)-1] + parent := vs.dir + + if len(key) > 1 { + parent = filepath.Join(append([]string{parent}, key[0:len(key)-1]...)...) + err := os.MkdirAll(parent, vs.dirPerm) + if err != nil { + return errors.Join(ErrSystemFailure, err) + } + } + + dest := filepath.Join(parent, fileName) + st, err := os.Stat(dest) + if err == nil { + if st.IsDir() { + return errors.Join(ErrFaultyImplementation, fmt.Errorf("%q is a directory and cannot be written to", dest)) + } + } + + return atomicWrite(parent, fileName, vs.filePerm, data) +} + +func (vs *fileStore) List(key ...string) ([]string, error) { + if vs.locked == nil { + return nil, errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + // Unlike Get, Set and Delete, List can have zero length key + for _, k := range key { + if err := ValidatePathComponent(k); err != nil { + return nil, err + } + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + st, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(ErrNotFound, err) + } + + return nil, errors.Join(ErrSystemFailure, err) + } + + if !st.IsDir() { + return nil, errors.Join(ErrFaultyImplementation, fmt.Errorf("%q is not a directory and cannot be enumerated", path)) + } + + dirEntries, err := os.ReadDir(path) + if err != nil { + return nil, errors.Join(ErrSystemFailure, err) + } + + entries := []string{} + for _, dirEntry := range dirEntries { + entries = append(entries, dirEntry.Name()) + } + + return entries, nil +} + +func (vs *fileStore) Delete(key ...string) error { + if vs.locked == nil { + return errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + if err := validateAllPathComponents(key...); err != nil { + return err + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + _, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return errors.Join(ErrNotFound, err) + } + + return errors.Join(ErrSystemFailure, err) + } + + if err = os.RemoveAll(path); err != nil { + return errors.Join(ErrSystemFailure, err) + } + + return nil +} + +func (vs *fileStore) Location(key ...string) (string, error) { + if err := validateAllPathComponents(key...); err != nil { + return "", err + } + + return filepath.Join(append([]string{vs.dir}, key...)...), nil +} + +func (vs *fileStore) GroupEnsure(key ...string) error { + if vs.locked == nil { + return errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + if err := validateAllPathComponents(key...); err != nil { + return err + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + if err := os.MkdirAll(path, vs.dirPerm); err != nil { + return errors.Join(ErrSystemFailure, err) + } + + return nil +} + +func (vs *fileStore) GroupSize(key ...string) (int64, error) { + if vs.locked == nil { + return 0, errors.Join(ErrFaultyImplementation, fmt.Errorf("operations on the store must use locking")) + } + + if err := validateAllPathComponents(key...); err != nil { + return 0, err + } + + path := filepath.Join(append([]string{vs.dir}, key...)...) + + st, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, errors.Join(ErrNotFound, err) + } + + return 0, errors.Join(ErrSystemFailure, err) + } + + if !st.IsDir() { + return 0, errors.Join(ErrFaultyImplementation, fmt.Errorf("%q is not a directory", path)) + } + + var size int64 + var walkFn = func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return err + } + + err = filepath.Walk(path, walkFn) + if err != nil { + return 0, err + } + + 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 { + return errors.Join(ErrInvalidArgument, errors.New("you must specify an identifier")) + } + + for _, key := range pathComponent { + if err := ValidatePathComponent(key); err != nil { + return 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) + } + + return nil +} diff --git a/pkg/store/filestore_test.go b/pkg/store/filestore_test.go new file mode 100644 index 00000000000..58f4eebeef0 --- /dev/null +++ b/pkg/store/filestore_test.go @@ -0,0 +1,279 @@ +/* + 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 store + +import ( + "fmt" + "runtime" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestFileStoreBasics(t *testing.T) { + tempDir := t.TempDir() + + // Creation + tempStore, err := New(tempDir, 0, 0) + assert.NilError(t, err, "temporary store creation should succeed") + + // Lock acquisition + err = tempStore.Lock() + assert.NilError(t, err, "acquiring a lock should succeed") + err = tempStore.Release() + assert.NilError(t, err, "releasing a lock should succeed") + + // Non-existent keys + _ = tempStore.Lock() + defer tempStore.Release() + + _, err = tempStore.Get("nonexistent") + assert.ErrorIs(t, err, ErrNotFound, "getting a non existent key should ErrNotFound") + + err = tempStore.Delete("nonexistent") + assert.ErrorIs(t, err, ErrNotFound, "deleting a non existent key should ErrNotFound") + + _, err = tempStore.List("nonexistent") + assert.ErrorIs(t, err, ErrNotFound, "listing a non existent key should ErrNotFound") + + doesExist, err := tempStore.Exists("nonexistent") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, !doesExist, "should not exist") + + // Listing empty store + result, err := tempStore.List() + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 0, "list empty store return zero length slice") + + // Invalid keys + _, err = tempStore.Get("..") + assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + + err = tempStore.Set([]byte("foo"), "..") + assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + + err = tempStore.Delete("..") + assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + + _, err = tempStore.List("..") + assert.ErrorIs(t, err, ErrInvalidArgument, "unsupported characters or patterns should return ErrInvalidArgument") + + // Writing, reading, listing, deleting + err = tempStore.Set([]byte("foo"), "something") + assert.NilError(t, err, "write should be successful") + + doesExist, err = tempStore.Exists("something") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, doesExist, "should exist") + + data, err := tempStore.Get("something") + assert.NilError(t, err, "read should be successful") + assert.Assert(t, string(data) == "foo", "written data should be read back") + + result, err = tempStore.List() + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 1, "list store with one element should return it") + + // Read from the list key obtained + data, err = tempStore.Get(result[0]) + assert.NilError(t, err, "read should be successful") + assert.Assert(t, string(data) == "foo", "written data should be read back") + + err = tempStore.Delete("something") + assert.NilError(t, err, "delete should be successful") + + doesExist, err = tempStore.Exists("something") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, !doesExist, "should not exist") + + result, err = tempStore.List() + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 0, "list store should return it empty slice") +} + +func TestFileStoreGroups(t *testing.T) { + tempDir := t.TempDir() + + // Creation + tempStore, err := New(tempDir, 0, 0) + assert.NilError(t, err, "temporary store creation should succeed") + + _ = tempStore.Lock() + defer tempStore.Release() + + err = tempStore.Set([]byte("foo"), "group", "subgroup", "actualkey") + assert.NilError(t, err, "write should be successful") + + doesExist, err := tempStore.Exists("group", "subgroup", "actualkey") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, doesExist, "should exist") + + data, err := tempStore.Get("group", "subgroup", "actualkey") + assert.NilError(t, err, "read should be successful") + assert.Assert(t, string(data) == "foo", "written data should be read back") + + result, err := tempStore.List() + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 1) + assert.Assert(t, result[0] == "group") + + result, err = tempStore.List("group") + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 1) + assert.Assert(t, result[0] == "subgroup") + + result, err = tempStore.List("group", "subgroup") + assert.NilError(t, err, "listing store root should succeed") + assert.Assert(t, len(result) == 1) + assert.Assert(t, result[0] == "actualkey") + + err = tempStore.Delete("group", "subgroup", "actualkey") + assert.NilError(t, err, "delete should be successful") + + doesExist, err = tempStore.Exists("group", "subgroup", "actualkey") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, !doesExist, "should not exist") + + doesExist, err = tempStore.Exists("group", "subgroup") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, doesExist, "should exist") + + err = tempStore.Delete("group", "subgroup") + assert.NilError(t, err, "delete should be successful") + + doesExist, err = tempStore.Exists("group", "subgroup") + assert.NilError(t, err, "exist should not error") + assert.Assert(t, !doesExist, "should not exist") + +} + +func TestFileStoreConcurrent(t *testing.T) { + tempDir := t.TempDir() + + // Creation + tempStore, err := New(tempDir, 0, 0) + assert.NilError(t, err, "temporary store creation should succeed") + + go func() { + lErr := tempStore.WithLock(func() error { + err := tempStore.Set([]byte("routine 1"), "concurrentkey") + assert.NilError(t, err, "writing should not error") + time.Sleep(1 * time.Second) + result, err := tempStore.Get("concurrentkey") + assert.NilError(t, err, "reading should not error") + assert.Assert(t, string(result) == "routine 1") + return nil + }) + assert.NilError(t, lErr, "locking should not error") + }() + + go func() { + time.Sleep(500 * time.Millisecond) + lErr := tempStore.WithLock(func() error { + err := tempStore.Set([]byte("routine 2"), "concurrentkey") + assert.NilError(t, err, "writing should not error") + time.Sleep(1 * time.Second) + result, err := tempStore.Get("concurrentkey") + assert.NilError(t, err, "reading should not error") + assert.Assert(t, string(result) == "routine 2") + return nil + }) + assert.NilError(t, lErr, "locking should not error") + }() + + lErr := tempStore.WithLock(func() error { + err := tempStore.Set([]byte("main routine 1"), "concurrentkey") + assert.NilError(t, err, "writing should not error") + time.Sleep(1 * time.Second) + result, err := tempStore.Get("concurrentkey") + assert.NilError(t, err, "reading should not error") + assert.Assert(t, string(result) == "main routine 1") + return nil + }) + assert.NilError(t, lErr, "locking should not error") + + time.Sleep(750 * time.Millisecond) + + lErr = tempStore.WithLock(func() error { + err := tempStore.Set([]byte("main routine 2"), "concurrentkey") + assert.NilError(t, err, "writing should not error") + time.Sleep(1 * time.Second) + result, err := tempStore.Get("concurrentkey") + assert.NilError(t, err, "reading should not error") + assert.Assert(t, string(result) == "main routine 2") + return nil + }) + 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/store/filestore_unix.go b/pkg/store/filestore_unix.go new file mode 100644 index 00000000000..b694b6fc744 --- /dev/null +++ b/pkg/store/filestore_unix.go @@ -0,0 +1,43 @@ +//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 store + +import ( + "fmt" + "regexp" +) + +// Note that Darwin has different restrictions - though, we do not support Darwin at this point... +// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names +var ( + disallowedKeywords = regexp.MustCompile(`^([.]|[.][.])$`) + reservedCharacters = regexp.MustCompile(`[\x{0}/]`) +) + +func validatePlatformSpecific(pathComponent string) error { + if reservedCharacters.MatchString(pathComponent) { + return fmt.Errorf("identifier %q cannot contain any of the following characters: %q", pathComponent, reservedCharacters) + } + + if disallowedKeywords.MatchString(pathComponent) { + return fmt.Errorf("identifier %q cannot be any of the reserved keywords: %q", pathComponent, disallowedKeywords) + } + + return nil +} diff --git a/pkg/store/filestore_windows.go b/pkg/store/filestore_windows.go new file mode 100644 index 00000000000..7c599803cb5 --- /dev/null +++ b/pkg/store/filestore_windows.go @@ -0,0 +1,45 @@ +/* + 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 store + +import ( + "fmt" + "regexp" +) + +// See https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file +// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names +var ( + disallowedKeywords = regexp.MustCompile(`(?i)^(con|prn|nul|aux|com[1-9¹²³]|lpt[1-9¹²³])([.].*)?$`) + reservedCharacters = regexp.MustCompile(`[\x{0}-\x{1f}<>:"/\\|?*]`) +) + +func validatePlatformSpecific(pathComponent string) error { + if reservedCharacters.MatchString(pathComponent) { + return fmt.Errorf("identifier %q cannot contain any of the following characters: %q", pathComponent, reservedCharacters) + } + + if disallowedKeywords.MatchString(pathComponent) { + return fmt.Errorf("identifier %q cannot be any of the reserved keywords: %q", 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 nil +} diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 00000000000..78b977dfaf9 --- /dev/null +++ b/pkg/store/store.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 store provides a concurrency-safe lightweight storage solution with a simple interface. +// Embedders should call `Lock` and `defer Release` (or WithLock(func()error)) to wrap operations, +// or series of operations, to ensure secure use. +// Furthermore, a Store implementation must do atomic writes, providing guarantees that interrupted partial writes +// never get committed. +// The Store interface itself is meant to be generic, and alternative stores (memory based, or content-addressable) +// may be implemented that satisfies it. +// This package also provides the default, file based implementation that we are using. +package store + +import ( + "errors" + + "github.com/containerd/errdefs" +) + +var ( + // ErrInvalidArgument may be returned by Get, Set, List, or Delete by specific SafeStore implementations + // (eg: filesystem), when they want to impose implementation dependent restrictions on the identifiers + // (filesystems typically do). + ErrInvalidArgument = errdefs.ErrInvalidArgument + // ErrNotFound may be returned by Get or Delete when the requested key is not present in the store + ErrNotFound = errdefs.ErrNotFound + // ErrSystemFailure may be returned by implementations when an internal failure occurs. + // For example, for a filesystem implementation, failure to create a file will be wrapped by this error. + ErrSystemFailure = errors.New("system failure") + // ErrLockFailure may be returned by ReadLock, WriteLock, or Unlock, when the underlying locking mechanism fails. + // In the case of the filesystem implementation, inability to lock the directory will return it. + ErrLockFailure = errors.New("lock failure") + // ErrFaultyImplementation may be returned by Get or Set when the target key exists and is a dir, + // or by List when the target key is a file + // This is indicative the code using the store is not consistent with what it treats as group, and what it treats as key + // and is definitely a bug in that code + // Missing lock will also trigger this when detected. + ErrFaultyImplementation = errors.New("code needs to be fixed") +) + +// Store represents a store that is able to grant an exclusive lock (ensuring concurrency safety, +// both between go routines and across multiple binaries invocations), and is performing atomic operations. +// Note that Store allows manipulating nested objects: +// - Set([]byte("mykeyvalue"), "group", "subgroup", "my key1") +// - Set([]byte("mykeyvalue"), "group", "subgroup", "my key2") +// - Get("group", "subgroup", "my key1") +// - List("group", "subgroup") +// Note that both Delete and Exists can be applied indifferently to specific keys, or groups. +type Store interface { + Locker + Manager +} + +// Manager describes operations that can be performed on the store +type Manager interface { + // List will return a slice of all subgroups (eg: subdirectories), or keys (eg: files), under a specific group (eg: dir) + // Note that `key...` may be omitted, in which case, all objects' names at the root of the store are returned. + // Example, in the volumestore, List() will return all existing volumes names + List(key ...string) ([]string, error) + // Exists checks that a given key exists + // Example: Exists("meta.json") + Exists(key ...string) (bool, error) + // Get returns the content of a key + Get(key ...string) ([]byte, error) + // Set saves bytes to a key + Set(data []byte, key ...string) error + // Delete removes a key or a group + Delete(key ...string) error + // Location returns the absolute path to a certain resource + // Note that this technically "leaks" (filesystem) implementation details up. + // It is necessary though when we are going to pass these filepath to containerd for eg. + Location(key ...string) (string, error) + + // GroupSize will return the combined size of all objects stored under the group (eg: dir) + GroupSize(key ...string) (int64, error) + // GroupEnsure ensures that a given group (eg: directory) exists + GroupEnsure(key ...string) error +} + +// Locker describes a locking mechanism that can be used to encapsulate operations in a safe way +type Locker interface { + Lock() error + Release() error + WithLock(fun func() error) (err error) +} diff --git a/pkg/strutil/strutil.go b/pkg/strutil/strutil.go index c47aaa9bceb..f478bffa9b2 100644 --- a/pkg/strutil/strutil.go +++ b/pkg/strutil/strutil.go @@ -36,7 +36,7 @@ import ( "strconv" "strings" - "github.com/containerd/containerd/errdefs" + "github.com/containerd/errdefs" ) // ConvertKVStringsToMap is from https://github.com/moby/moby/blob/v20.10.0-rc2/runconfig/opts/parse.go @@ -81,6 +81,19 @@ func DedupeStrSlice(in []string) []string { return res } +// SliceToSet converts a slice of strings into a set. +// In Go, a set is often represented as a map with keys as the set elements and values as boolean. +// This function iterates over the slice, adding each string as a key in the map. +// The corresponding map value is set to true, serving as a placeholder. +// The resulting map can be used to quickly check the presence of an element in the set. +func SliceToSet(in []string) map[string]bool { + set := make(map[string]bool) + for _, s := range in { + set[s] = true + } + return set +} + // ParseCSVMap parses a string like "foo=x,bar=y" into a map func ParseCSVMap(s string) (map[string]string, error) { csvR := csv.NewReader(strings.NewReader(s)) diff --git a/pkg/strutil/strutil_test.go b/pkg/strutil/strutil_test.go index 9bcae5624a1..4be59519962 100644 --- a/pkg/strutil/strutil_test.go +++ b/pkg/strutil/strutil_test.go @@ -34,6 +34,17 @@ func TestDedupeStrSlice(t *testing.T) { } +func TestSliceToSet(t *testing.T) { + assert.DeepEqual(t, + map[string]bool{"apple": true, "banana": true, "chocolate": true}, + SliceToSet([]string{"apple", "banana", "apple", "chocolate"})) + + assert.DeepEqual(t, + map[string]bool{"apple": true, "banana": true, "chocolate": true}, + SliceToSet([]string{"apple", "apple", "banana", "chocolate", "apple"})) + +} + func TestTrimStrSliceRight(t *testing.T) { assert.DeepEqual(t, []string{"foo", "bar", "baz"}, diff --git a/pkg/sysinfo/cgroup2_linux.go b/pkg/sysinfo/cgroup2_linux.go new file mode 100644 index 00000000000..d8dbe27eec0 --- /dev/null +++ b/pkg/sysinfo/cgroup2_linux.go @@ -0,0 +1,169 @@ +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/cgroup2_linux.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import ( + "context" + "os" + "path" + "strings" + + "github.com/moby/sys/userns" + + "github.com/containerd/cgroups/v3" + cgroupsV2 "github.com/containerd/cgroups/v3/cgroup2" + "github.com/containerd/log" +) + +func newV2(options ...Opt) *SysInfo { + sysInfo := &SysInfo{ + CgroupUnified: true, + cg2GroupPath: "/", + } + for _, o := range options { + o(sysInfo) + } + + ops := []infoCollector{ + applyNetworkingInfo, + applyAppArmorInfo, + applySeccompInfo, + applyCgroupNsInfo, + } + + m, err := cgroupsV2.Load(sysInfo.cg2GroupPath) + if err != nil { + log.G(context.TODO()).Warn(err) + } else { + sysInfo.cg2Controllers = make(map[string]struct{}) + controllers, err := m.Controllers() + if err != nil { + log.G(context.TODO()).Warn(err) + } + for _, c := range controllers { + sysInfo.cg2Controllers[c] = struct{}{} + } + ops = append(ops, + applyMemoryCgroupInfoV2, + applyCPUCgroupInfoV2, + applyIOCgroupInfoV2, + applyCPUSetCgroupInfoV2, + applyPIDSCgroupInfoV2, + applyDevicesCgroupInfoV2, + ) + } + + for _, o := range ops { + o(sysInfo) + } + return sysInfo +} + +func getSwapLimitV2() bool { + _, g, err := cgroups.ParseCgroupFileUnified("/proc/self/cgroup") + if err != nil { + return false + } + + if g == "" { + return false + } + + cGroupPath := path.Join("/sys/fs/cgroup", g, "memory.swap.max") + if _, err = os.Stat(cGroupPath); os.IsNotExist(err) { + return false + } + return true +} + +func applyMemoryCgroupInfoV2(info *SysInfo) { + if _, ok := info.cg2Controllers["memory"]; !ok { + info.Warnings = append(info.Warnings, "Unable to find memory controller") + return + } + + info.MemoryLimit = true + info.SwapLimit = getSwapLimitV2() + info.MemoryReservation = true + info.OomKillDisable = false + info.MemorySwappiness = false + info.KernelMemory = false + info.KernelMemoryTCP = false +} + +func applyCPUCgroupInfoV2(info *SysInfo) { + if _, ok := info.cg2Controllers["cpu"]; !ok { + info.Warnings = append(info.Warnings, "Unable to find cpu controller") + return + } + info.CPUShares = true + info.CPUCfs = true + info.CPURealtime = false +} + +func applyIOCgroupInfoV2(info *SysInfo) { + if _, ok := info.cg2Controllers["io"]; !ok { + info.Warnings = append(info.Warnings, "Unable to find io controller") + return + } + + info.BlkioWeight = true + info.BlkioWeightDevice = true + info.BlkioReadBpsDevice = true + info.BlkioWriteBpsDevice = true + info.BlkioReadIOpsDevice = true + info.BlkioWriteIOpsDevice = true +} + +func applyCPUSetCgroupInfoV2(info *SysInfo) { + if _, ok := info.cg2Controllers["cpuset"]; !ok { + info.Warnings = append(info.Warnings, "Unable to find cpuset controller") + return + } + info.Cpuset = true + + cpus, err := os.ReadFile(path.Join("/sys/fs/cgroup", info.cg2GroupPath, "cpuset.cpus.effective")) + if err != nil { + return + } + info.Cpus = strings.TrimSpace(string(cpus)) + + mems, err := os.ReadFile(path.Join("/sys/fs/cgroup", info.cg2GroupPath, "cpuset.mems.effective")) + if err != nil { + return + } + info.Mems = strings.TrimSpace(string(mems)) +} + +func applyPIDSCgroupInfoV2(info *SysInfo) { + if _, ok := info.cg2Controllers["pids"]; !ok { + info.Warnings = append(info.Warnings, "Unable to find pids controller") + return + } + info.PidsLimit = true +} + +func applyDevicesCgroupInfoV2(info *SysInfo) { + info.CgroupDevicesEnabled = !userns.RunningInUserNS() +} diff --git a/pkg/sysinfo/doc.go b/pkg/sysinfo/doc.go new file mode 100644 index 00000000000..17515398fbd --- /dev/null +++ b/pkg/sysinfo/doc.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 sysinfo is a copy of https://github.com/moby/moby/tree/master/pkg/sysinfo +// as of cff4f20c44a3a7c882ed73934dec6a77246c6323 +// This may be removed (and replaced by a dependency to moby again) once they +// have migrated to containerd v2. +package sysinfo diff --git a/pkg/sysinfo/numcpu.go b/pkg/sysinfo/numcpu.go new file mode 100644 index 00000000000..9b585a11d88 --- /dev/null +++ b/pkg/sysinfo/numcpu.go @@ -0,0 +1,38 @@ +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/numcpu.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import ( + "runtime" +) + +// NumCPU returns the number of CPUs. On Linux and Windows, it returns +// the number of CPUs which are currently online. On other platforms, +// it's the equivalent of [runtime.NumCPU]. +func NumCPU() int { + if ncpu := numCPU(); ncpu > 0 { + return ncpu + } + return runtime.NumCPU() +} diff --git a/pkg/sysinfo/numcpu_linux.go b/pkg/sysinfo/numcpu_linux.go new file mode 100644 index 00000000000..de590919ae4 --- /dev/null +++ b/pkg/sysinfo/numcpu_linux.go @@ -0,0 +1,40 @@ +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/numcpu_linux.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import "golang.org/x/sys/unix" + +// numCPU queries the system for the count of threads available +// for use to this process. +// +// Returns 0 on errors. Use |runtime.NumCPU| in that case. +func numCPU() int { + // Gets the affinity mask for a process: The very one invoking this function. + var mask unix.CPUSet + err := unix.SchedGetaffinity(0, &mask) + if err != nil { + return 0 + } + return mask.Count() +} diff --git a/pkg/sysinfo/numcpu_other.go b/pkg/sysinfo/numcpu_other.go new file mode 100644 index 00000000000..cd0c676b440 --- /dev/null +++ b/pkg/sysinfo/numcpu_other.go @@ -0,0 +1,31 @@ +//go:build !linux && !windows + +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/numcpu_other.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo + +func numCPU() int { + // not implemented + return 0 +} diff --git a/pkg/sysinfo/numcpu_windows.go b/pkg/sysinfo/numcpu_windows.go new file mode 100644 index 00000000000..dfa6496896d --- /dev/null +++ b/pkg/sysinfo/numcpu_windows.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. +*/ + +/* + Portions from https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/numcpu_windows.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + getCurrentProcess = kernel32.NewProc("GetCurrentProcess") + getProcessAffinityMask = kernel32.NewProc("GetProcessAffinityMask") +) + +// Returns bit count of 1, used by NumCPU +func popcnt(x uint64) (n byte) { + x -= (x >> 1) & 0x5555555555555555 + x = (x>>2)&0x3333333333333333 + x&0x3333333333333333 + x += x >> 4 + x &= 0x0f0f0f0f0f0f0f0f + x *= 0x0101010101010101 + return byte(x >> 56) +} + +func numCPU() int { + // Gets the affinity mask for a process + var mask, sysmask uintptr + currentProcess, _, _ := getCurrentProcess.Call() + ret, _, _ := getProcessAffinityMask.Call(currentProcess, uintptr(unsafe.Pointer(&mask)), uintptr(unsafe.Pointer(&sysmask))) + if ret == 0 { + return 0 + } + // For every available thread a bit is set in the mask. + ncpu := int(popcnt(uint64(mask))) + return ncpu +} diff --git a/pkg/sysinfo/sysinfo.go b/pkg/sysinfo/sysinfo.go new file mode 100644 index 00000000000..0e150222c9b --- /dev/null +++ b/pkg/sysinfo/sysinfo.go @@ -0,0 +1,193 @@ +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/sysinfo.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +// Package sysinfo stores information about which features a kernel supports. +package sysinfo // import "github.com/docker/docker/" + +import "github.com/docker/docker/pkg/parsers" + +// Opt for New(). +type Opt func(info *SysInfo) + +// SysInfo stores information about which features a kernel supports. +// TODO Windows: Factor out platform specific capabilities. +type SysInfo struct { + // Whether the kernel supports AppArmor or not + AppArmor bool + // Whether the kernel supports Seccomp or not + Seccomp bool + + cgroupMemInfo + cgroupCPUInfo + cgroupBlkioInfo + cgroupCpusetInfo + cgroupPids + + // Whether the kernel supports cgroup namespaces or not + CgroupNamespaces bool + + // Whether IPv4 forwarding is supported or not, if this was disabled, networking will not work + IPv4ForwardingDisabled bool + + // Whether bridge-nf-call-iptables is supported or not + BridgeNFCallIPTablesDisabled bool + + // Whether bridge-nf-call-ip6tables is supported or not + BridgeNFCallIP6TablesDisabled bool + + // Whether the cgroup has the mountpoint of "devices" or not + CgroupDevicesEnabled bool + + // Whether the cgroup is in unified mode (v2). + CgroupUnified bool + + // Warnings contains a slice of warnings that occurred while collecting + // system information. These warnings are intended to be informational + // messages for the user, and can either be logged or returned to the + // client; they are not intended to be parsed / used for other purposes, + // and do not have a fixed format. + Warnings []string + + // cgMounts is the list of cgroup v1 mount paths, indexed by subsystem, to + // inspect availability of subsystems. + cgMounts map[string]string //nolint:unused + + // cg2GroupPath is the cgroup v2 group path to inspect availability of the controllers. + cg2GroupPath string //nolint:unused + + // cg2Controllers is an index of available cgroup v2 controllers. + cg2Controllers map[string]struct{} //nolint:unused +} + +type cgroupMemInfo struct { + // Whether memory limit is supported or not + MemoryLimit bool + + // Whether swap limit is supported or not + SwapLimit bool + + // Whether soft limit is supported or not + MemoryReservation bool + + // Whether OOM killer disable is supported or not + OomKillDisable bool + + // Whether memory swappiness is supported or not + MemorySwappiness bool + + // Whether kernel memory limit is supported or not. This option is used to + // detect support for kernel-memory limits on API < v1.42. Kernel memory + // limit (`kmem.limit_in_bytes`) is not supported on cgroups v2, and has been + // removed in kernel 5.4. + KernelMemory bool + + // Whether kernel memory TCP limit is supported or not. Kernel memory TCP + // limit (`memory.kmem.tcp.limit_in_bytes`) is not supported on cgroups v2. + KernelMemoryTCP bool +} + +type cgroupCPUInfo struct { + // Whether CPU shares is supported or not + CPUShares bool + + // Whether CPU CFS (Completely Fair Scheduler) is supported + CPUCfs bool + + // Whether CPU real-time scheduler is supported + CPURealtime bool +} + +type cgroupBlkioInfo struct { + // Whether Block IO weight is supported or not + BlkioWeight bool + + // Whether Block IO weight_device is supported or not + BlkioWeightDevice bool + + // Whether Block IO read limit in bytes per second is supported or not + BlkioReadBpsDevice bool + + // Whether Block IO write limit in bytes per second is supported or not + BlkioWriteBpsDevice bool + + // Whether Block IO read limit in IO per second is supported or not + BlkioReadIOpsDevice bool + + // Whether Block IO write limit in IO per second is supported or not + BlkioWriteIOpsDevice bool +} + +type cgroupCpusetInfo struct { + // Whether Cpuset is supported or not + Cpuset bool + + // Available Cpuset's cpus + Cpus string + + // Available Cpuset's memory nodes + Mems string +} + +type cgroupPids struct { + // Whether Pids Limit is supported or not + PidsLimit bool +} + +// IsCpusetCpusAvailable returns `true` if the provided string set is contained +// in cgroup's cpuset.cpus set, `false` otherwise. +// If error is not nil a parsing error occurred. +func (c cgroupCpusetInfo) IsCpusetCpusAvailable(provided string) (bool, error) { + return isCpusetListAvailable(provided, c.Cpus) +} + +// IsCpusetMemsAvailable returns `true` if the provided string set is contained +// in cgroup's cpuset.mems set, `false` otherwise. +// If error is not nil a parsing error occurred. +func (c cgroupCpusetInfo) IsCpusetMemsAvailable(provided string) (bool, error) { + return isCpusetListAvailable(provided, c.Mems) +} + +func isCpusetListAvailable(provided, available string) (bool, error) { + parsedAvailable, err := parsers.ParseUintList(available) + if err != nil { + return false, err + } + // 8192 is the normal maximum number of CPUs in Linux, so accept numbers up to this + // or more if we actually have more CPUs. + maxCPUs := 8192 + for m := range parsedAvailable { + if m > maxCPUs { + maxCPUs = m + } + } + parsedProvided, err := parsers.ParseUintListMaximum(provided, maxCPUs) + if err != nil { + return false, err + } + for k := range parsedProvided { + if !parsedAvailable[k] { + return false, nil + } + } + return true, nil +} diff --git a/pkg/sysinfo/sysinfo_linux.go b/pkg/sysinfo/sysinfo_linux.go new file mode 100644 index 00000000000..f8b50440878 --- /dev/null +++ b/pkg/sysinfo/sysinfo_linux.go @@ -0,0 +1,330 @@ +/* + 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/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/sysinfo_linux.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import ( + "context" + "fmt" + "os" + "path" + "strings" + "sync" + + "github.com/moby/sys/mountinfo" + + "github.com/containerd/cgroups/v3" + "github.com/containerd/cgroups/v3/cgroup1" + "github.com/containerd/containerd/v2/pkg/seccomp" + "github.com/containerd/log" +) + +var ( + readMountinfoOnce sync.Once + readMountinfoErr error + cgroupMountinfo []*mountinfo.Info +) + +// readCgroupMountinfo returns a list of cgroup v1 mounts (i.e. the ones +// with fstype of "cgroup") for the current running process. +// +// The results are cached (to avoid re-reading mountinfo which is relatively +// expensive), so it is assumed that cgroup mounts are not being changed. +func readCgroupMountinfo() ([]*mountinfo.Info, error) { + readMountinfoOnce.Do(func() { + cgroupMountinfo, readMountinfoErr = mountinfo.GetMounts( + mountinfo.FSTypeFilter("cgroup"), + ) + }) + + return cgroupMountinfo, readMountinfoErr +} + +func findCgroupV1Mountpoints() (map[string]string, error) { + mounts, err := readCgroupMountinfo() + if err != nil { + return nil, err + } + + allSubsystems, err := cgroup1.ParseCgroupFile("/proc/self/cgroup") + if err != nil { + return nil, fmt.Errorf("failed to parse cgroup information: %v", err) + } + + allMap := make(map[string]bool) + for s := range allSubsystems { + allMap[s] = false + } + + mps := make(map[string]string) + for _, mi := range mounts { + for _, opt := range strings.Split(mi.VFSOptions, ",") { + seen, known := allMap[opt] + if known && !seen { + allMap[opt] = true + mps[strings.TrimPrefix(opt, "name=")] = mi.Mountpoint + } + } + if len(mps) >= len(allMap) { + break + } + } + return mps, nil +} + +type infoCollector func(info *SysInfo) + +// WithCgroup2GroupPath specifies the cgroup v2 group path to inspect availability +// of the controllers. +// +// WithCgroup2GroupPath is expected to be used for rootless mode with systemd driver. +// +// e.g. g = "/user.slice/user-1000.slice/user@1000.service" +func WithCgroup2GroupPath(g string) Opt { + return func(o *SysInfo) { + if p := path.Clean(g); p != "" { + o.cg2GroupPath = p + } + } +} + +// New returns a new SysInfo, using the filesystem to detect which features +// the kernel supports. +func New(options ...Opt) *SysInfo { + if cgroups.Mode() == cgroups.Unified { + return newV2(options...) + } + return newV1() +} + +func newV1() *SysInfo { + var ( + err error + sysInfo = &SysInfo{} + ) + + ops := []infoCollector{ + applyNetworkingInfo, + applyAppArmorInfo, + applySeccompInfo, + applyCgroupNsInfo, + } + + sysInfo.cgMounts, err = findCgroupV1Mountpoints() + if err != nil { + log.G(context.TODO()).Warn(err) + } else { + ops = append(ops, + applyMemoryCgroupInfo, + applyCPUCgroupInfo, + applyBlkioCgroupInfo, + applyCPUSetCgroupInfo, + applyPIDSCgroupInfo, + applyDevicesCgroupInfo, + ) + } + + for _, o := range ops { + o(sysInfo) + } + return sysInfo +} + +// applyMemoryCgroupInfo adds the memory cgroup controller information to the info. +func applyMemoryCgroupInfo(info *SysInfo) { + mountPoint, ok := info.cgMounts["memory"] + if !ok { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup memory limit") + return + } + info.MemoryLimit = ok + + info.SwapLimit = cgroupEnabled(mountPoint, "memory.memsw.limit_in_bytes") + if !info.SwapLimit { + info.Warnings = append(info.Warnings, "Your kernel does not support swap memory limit") + } + info.MemoryReservation = cgroupEnabled(mountPoint, "memory.soft_limit_in_bytes") + if !info.MemoryReservation { + info.Warnings = append(info.Warnings, "Your kernel does not support memory reservation") + } + info.OomKillDisable = cgroupEnabled(mountPoint, "memory.oom_control") + if !info.OomKillDisable { + info.Warnings = append(info.Warnings, "Your kernel does not support oom control") + } + info.MemorySwappiness = cgroupEnabled(mountPoint, "memory.swappiness") + if !info.MemorySwappiness { + info.Warnings = append(info.Warnings, "Your kernel does not support memory swappiness") + } + + // Option is deprecated, but still accepted on API < v1.42 with cgroups v1, + // so setting the field to allow feature detection. + info.KernelMemory = cgroupEnabled(mountPoint, "memory.kmem.limit_in_bytes") + + // Option is deprecated in runc, but still accepted in our API, so setting + // the field to allow feature detection, but don't warn if it's missing, to + // make the daemon logs a bit less noisy. + info.KernelMemoryTCP = cgroupEnabled(mountPoint, "memory.kmem.tcp.limit_in_bytes") +} + +// applyCPUCgroupInfo adds the cpu cgroup controller information to the info. +func applyCPUCgroupInfo(info *SysInfo) { + mountPoint, ok := info.cgMounts["cpu"] + if !ok { + info.Warnings = append(info.Warnings, "Unable to find cpu cgroup in mounts") + return + } + + info.CPUShares = cgroupEnabled(mountPoint, "cpu.shares") + if !info.CPUShares { + info.Warnings = append(info.Warnings, "Your kernel does not support CPU shares") + } + + info.CPUCfs = cgroupEnabled(mountPoint, "cpu.cfs_quota_us") + if !info.CPUCfs { + info.Warnings = append(info.Warnings, "Your kernel does not support CPU CFS scheduler") + } + + info.CPURealtime = cgroupEnabled(mountPoint, "cpu.rt_period_us") + if !info.CPURealtime { + info.Warnings = append(info.Warnings, "Your kernel does not support CPU realtime scheduler") + } +} + +// applyBlkioCgroupInfo adds the blkio cgroup controller information to the info. +func applyBlkioCgroupInfo(info *SysInfo) { + mountPoint, ok := info.cgMounts["blkio"] + if !ok { + info.Warnings = append(info.Warnings, "Unable to find blkio cgroup in mounts") + return + } + + info.BlkioWeight = cgroupEnabled(mountPoint, "blkio.weight") + if !info.BlkioWeight { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio weight") + } + + info.BlkioWeightDevice = cgroupEnabled(mountPoint, "blkio.weight_device") + if !info.BlkioWeightDevice { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio weight_device") + } + + info.BlkioReadBpsDevice = cgroupEnabled(mountPoint, "blkio.throttle.read_bps_device") + if !info.BlkioReadBpsDevice { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio throttle.read_bps_device") + } + + info.BlkioWriteBpsDevice = cgroupEnabled(mountPoint, "blkio.throttle.write_bps_device") + if !info.BlkioWriteBpsDevice { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio throttle.write_bps_device") + } + info.BlkioReadIOpsDevice = cgroupEnabled(mountPoint, "blkio.throttle.read_iops_device") + if !info.BlkioReadIOpsDevice { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio throttle.read_iops_device") + } + + info.BlkioWriteIOpsDevice = cgroupEnabled(mountPoint, "blkio.throttle.write_iops_device") + if !info.BlkioWriteIOpsDevice { + info.Warnings = append(info.Warnings, "Your kernel does not support cgroup blkio throttle.write_iops_device") + } +} + +// applyCPUSetCgroupInfo adds the cpuset cgroup controller information to the info. +func applyCPUSetCgroupInfo(info *SysInfo) { + mountPoint, ok := info.cgMounts["cpuset"] + if !ok { + info.Warnings = append(info.Warnings, "Unable to find cpuset cgroup in mounts") + return + } + info.Cpuset = ok + + var err error + + cpus, err := os.ReadFile(path.Join(mountPoint, "cpuset.cpus")) + if err != nil { + return + } + info.Cpus = strings.TrimSpace(string(cpus)) + + mems, err := os.ReadFile(path.Join(mountPoint, "cpuset.mems")) + if err != nil { + return + } + info.Mems = strings.TrimSpace(string(mems)) +} + +// applyPIDSCgroupInfo adds whether the pids cgroup controller is available to the info. +func applyPIDSCgroupInfo(info *SysInfo) { + _, ok := info.cgMounts["pids"] + if !ok { + info.Warnings = append(info.Warnings, "Unable to find pids cgroup in mounts") + return + } + info.PidsLimit = true +} + +// applyDevicesCgroupInfo adds whether the devices cgroup controller is available to the info. +func applyDevicesCgroupInfo(info *SysInfo) { + _, ok := info.cgMounts["devices"] + info.CgroupDevicesEnabled = ok +} + +// applyNetworkingInfo adds networking information to the info. +func applyNetworkingInfo(info *SysInfo) { + info.IPv4ForwardingDisabled = !readProcBool("/proc/sys/net/ipv4/ip_forward") + info.BridgeNFCallIPTablesDisabled = !readProcBool("/proc/sys/net/bridge/bridge-nf-call-iptables") + info.BridgeNFCallIP6TablesDisabled = !readProcBool("/proc/sys/net/bridge/bridge-nf-call-ip6tables") +} + +// applyAppArmorInfo adds whether AppArmor is enabled to the info. +func applyAppArmorInfo(info *SysInfo) { + if _, err := os.Stat("/sys/kernel/security/apparmor"); !os.IsNotExist(err) { + if _, err := os.ReadFile("/sys/kernel/security/apparmor/profiles"); err == nil { + info.AppArmor = true + } + } +} + +// applyCgroupNsInfo adds whether cgroupns is enabled to the info. +func applyCgroupNsInfo(info *SysInfo) { + if _, err := os.Stat("/proc/self/ns/cgroup"); !os.IsNotExist(err) { + info.CgroupNamespaces = true + } +} + +// applySeccompInfo checks if Seccomp is supported, via CONFIG_SECCOMP. +func applySeccompInfo(info *SysInfo) { + info.Seccomp = seccomp.IsEnabled() +} + +func cgroupEnabled(mountPoint, name string) bool { + _, err := os.Stat(path.Join(mountPoint, name)) + return err == nil +} + +func readProcBool(path string) bool { + val, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.TrimSpace(string(val)) == "1" +} diff --git a/pkg/sysinfo/sysinfo_linux_test.go b/pkg/sysinfo/sysinfo_linux_test.go new file mode 100644 index 00000000000..7bfc3b6995d --- /dev/null +++ b/pkg/sysinfo/sysinfo_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. +*/ + +/* + Portions from https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/sysinfo_linux_test.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import ( + "os" + "path" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" +) + +func TestReadProcBool(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-sysinfo-proc") + assert.NilError(t, err) + defer os.RemoveAll(tmpDir) + + procFile := filepath.Join(tmpDir, "read-proc-bool") + err = os.WriteFile(procFile, []byte("1"), 0o644) + assert.NilError(t, err) + + if !readProcBool(procFile) { + t.Fatal("expected proc bool to be true, got false") + } + + if err := os.WriteFile(procFile, []byte("0"), 0o644); err != nil { + t.Fatal(err) + } + if readProcBool(procFile) { + t.Fatal("expected proc bool to be false, got true") + } + + if readProcBool(path.Join(tmpDir, "no-exist")) { + t.Fatal("should be false for non-existent entry") + } +} + +func TestCgroupEnabled(t *testing.T) { + cgroupDir, err := os.MkdirTemp("", "cgroup-test") + assert.NilError(t, err) + defer os.RemoveAll(cgroupDir) + + if cgroupEnabled(cgroupDir, "test") { + t.Fatal("cgroupEnabled should be false") + } + + err = os.WriteFile(path.Join(cgroupDir, "test"), []byte{}, 0o644) + assert.NilError(t, err) + + if !cgroupEnabled(cgroupDir, "test") { + t.Fatal("cgroupEnabled should be true") + } +} + +func TestNew(t *testing.T) { + sysInfo := New() + assert.Assert(t, sysInfo != nil) + checkSysInfo(t, sysInfo) +} + +func checkSysInfo(t *testing.T, sysInfo *SysInfo) { + // Check if Seccomp is supported, via CONFIG_SECCOMP.then sysInfo.Seccomp must be TRUE , else FALSE + if err := unix.Prctl(unix.PR_GET_SECCOMP, 0, 0, 0, 0); err != unix.EINVAL { + // Make sure the kernel has CONFIG_SECCOMP_FILTER. + if err := unix.Prctl(unix.PR_SET_SECCOMP, unix.SECCOMP_MODE_FILTER, 0, 0, 0); err != unix.EINVAL { + assert.Assert(t, sysInfo.Seccomp) + } + } else { + assert.Assert(t, !sysInfo.Seccomp) + } +} + +func TestNewAppArmorEnabled(t *testing.T) { + // Check if AppArmor is supported. then it must be TRUE , else FALSE + if _, err := os.Stat("/sys/kernel/security/apparmor"); err != nil { + t.Skip("AppArmor Must be Enabled") + } + + // FIXME: rootless is not allowed to read the profile + if rootlessutil.IsRootless() { + t.Skip("containerd v2 aftermath: test skipped for rootless") + } + sysInfo := New() + assert.Assert(t, sysInfo.AppArmor) +} + +func TestNewAppArmorDisabled(t *testing.T) { + // Check if AppArmor is supported. then it must be TRUE , else FALSE + if _, err := os.Stat("/sys/kernel/security/apparmor"); !os.IsNotExist(err) { + t.Skip("AppArmor Must be Disabled") + } + + sysInfo := New() + assert.Assert(t, !sysInfo.AppArmor) +} + +func TestNewCgroupNamespacesEnabled(t *testing.T) { + // If cgroup namespaces are supported in the kernel, then sysInfo.CgroupNamespaces should be TRUE + if _, err := os.Stat("/proc/self/ns/cgroup"); err != nil { + t.Skip("cgroup namespaces must be enabled") + } + + sysInfo := New() + assert.Assert(t, sysInfo.CgroupNamespaces) +} + +func TestNewCgroupNamespacesDisabled(t *testing.T) { + // If cgroup namespaces are *not* supported in the kernel, then sysInfo.CgroupNamespaces should be FALSE + if _, err := os.Stat("/proc/self/ns/cgroup"); !os.IsNotExist(err) { + t.Skip("cgroup namespaces must be disabled") + } + + sysInfo := New() + assert.Assert(t, !sysInfo.CgroupNamespaces) +} + +func TestNumCPU(t *testing.T) { + cpuNumbers := NumCPU() + if cpuNumbers <= 0 { + t.Fatal("CPU returned must be greater than zero") + } +} diff --git a/cmd/nerdctl/container_remove_linux_test.go b/pkg/sysinfo/sysinfo_other.go similarity index 54% rename from cmd/nerdctl/container_remove_linux_test.go rename to pkg/sysinfo/sysinfo_other.go index 3020dfc42f4..c48608a1909 100644 --- a/cmd/nerdctl/container_remove_linux_test.go +++ b/pkg/sysinfo/sysinfo_other.go @@ -1,3 +1,5 @@ +//go:build !linux + /* Copyright The containerd Authors. @@ -14,26 +16,16 @@ limitations under the License. */ -package main - -import ( - "testing" - - "github.com/containerd/nerdctl/pkg/testutil" -) - -func TestRemoveContainer(t *testing.T) { - t.Parallel() - base := testutil.NewBase(t) - tID := testutil.Identifier(t) - - // ignore error - base.Cmd("rm", tID, "-f").AssertOK() +/* + Portions from https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/sysinfo_other.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ - base.Cmd("run", "-d", "--name", tID, testutil.CommonImage, "sleep", "infinity").AssertOK() - defer base.Cmd("rm", tID, "-f").AssertOK() - base.Cmd("rm", tID).AssertFail() +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" - base.Cmd("kill", tID).AssertOK() - base.Cmd("rm", tID).AssertOK() +// New returns an empty SysInfo for non linux for now. +func New(options ...Opt) *SysInfo { + return &SysInfo{} } diff --git a/pkg/sysinfo/sysinfo_test.go b/pkg/sysinfo/sysinfo_test.go new file mode 100644 index 00000000000..045036a602d --- /dev/null +++ b/pkg/sysinfo/sysinfo_test.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. +*/ + +/* + Portions from https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/pkg/sysinfo/sysinfo_test.go + Copyright (C) Docker/Moby authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/moby/moby/blob/cff4f20c44a3a7c882ed73934dec6a77246c6323/NOTICE +*/ + +package sysinfo // import "github.com/docker/docker/pkg/sysinfo" + +import "testing" + +func TestIsCpusetListAvailable(t *testing.T) { + cases := []struct { + provided string + available string + res bool + err bool + }{ + {"1", "0-4", true, false}, + {"01,3", "0-4", true, false}, + {"", "0-7", true, false}, + {"1--42", "0-7", false, true}, + {"1-42", "00-1,8,,9", false, true}, + {"1,41-42", "43,45", false, false}, + {"0-3", "", false, false}, + } + for _, c := range cases { + r, err := isCpusetListAvailable(c.provided, c.available) + if (c.err && err == nil) && r != c.res { + t.Fatalf("Expected pair: %v, %v for %s, %s. Got %v, %v instead", c.res, c.err, c.provided, c.available, (c.err && err == nil), r) + } + } +} diff --git a/pkg/systemutil/socket_unix.go b/pkg/systemutil/socket_unix.go index b8e2f1e6904..6d41bab69b3 100644 --- a/pkg/systemutil/socket_unix.go +++ b/pkg/systemutil/socket_unix.go @@ -1,4 +1,4 @@ -//go:build freebsd || linux +//go:build unix /* Copyright The containerd Authors. diff --git a/pkg/tarutil/tarutil.go b/pkg/tarutil/tarutil.go index 74019144d49..eb7b4600cf2 100644 --- a/pkg/tarutil/tarutil.go +++ b/pkg/tarutil/tarutil.go @@ -22,7 +22,7 @@ import ( "os/exec" "strings" - "github.com/sirupsen/logrus" + "github.com/containerd/log" ) // FindTarBinary returns a path to the tar binary and whether it is GNU tar. @@ -30,11 +30,11 @@ func FindTarBinary() (string, bool, error) { isGNU := func(exe string) bool { v, err := exec.Command(exe, "--version").Output() if err != nil { - logrus.Warnf("Failed to detect whether %q is GNU tar or not", exe) + log.L.Warnf("Failed to detect whether %q is GNU tar or not", exe) return false } if !strings.Contains(string(v), "GNU tar") { - logrus.Warnf("%q does not seem GNU tar", exe) + log.L.Warnf("%q does not seem GNU tar", exe) return false } return true diff --git a/pkg/taskutil/taskutil.go b/pkg/taskutil/taskutil.go index 1f6fae17f2f..35ac3d8effc 100644 --- a/pkg/taskutil/taskutil.go +++ b/pkg/taskutil/taskutil.go @@ -23,22 +23,28 @@ import ( "net/url" "os" "runtime" + "slices" + "strings" "sync" "syscall" "github.com/Masterminds/semver/v3" - "github.com/containerd/console" - "github.com/containerd/containerd" - "github.com/containerd/containerd/cio" - "github.com/containerd/nerdctl/pkg/consoleutil" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/sirupsen/logrus" "golang.org/x/term" + + "github.com/containerd/console" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/cio" + "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" ) // 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, - flagA, flagI, flagT, flagD bool, con console.Console, logURI, detachKeys string, detachC chan<- struct{}) (containerd.Task, error) { + attachStreamOpt []string, flagI, flagT, flagD bool, con console.Console, logURI, detachKeys, namespace string, detachC chan<- struct{}) (containerd.Task, error) { + var t containerd.Task closer := func() { if detachC != nil { @@ -52,14 +58,14 @@ func NewTask(ctx context.Context, client *containerd.Client, container container // [1] https://github.com/containerd/containerd/blob/8f756bc8c26465bd93e78d9cd42082b66f276e10/cio/io.go#L358-L359 io := t.IO() if io == nil { - logrus.Errorf("got a nil io") + log.G(ctx).Errorf("got a nil io") return } io.Cancel() } var ioCreator cio.Creator - if flagA { - logrus.Debug("attaching output instead of using the log-uri") + if len(attachStreamOpt) != 0 { + log.G(ctx).Debug("attaching output instead of using the log-uri") if flagT { in, err := consoleutil.NewDetachableStdin(con, detachKeys, closer) if err != nil { @@ -67,7 +73,8 @@ func NewTask(ctx context.Context, client *containerd.Client, container container } ioCreator = cio.NewCreator(cio.WithStreams(in, con, nil), cio.WithTerminal) } else { - ioCreator = cio.NewCreator(cio.WithStdio) + streams := processAttachStreamsOpt(attachStreamOpt) + ioCreator = cio.NewCreator(cio.WithStreams(streams.stdIn, streams.stdOut, streams.stdErr)) } } else if flagT && flagD { @@ -89,7 +96,12 @@ func NewTask(ctx context.Context, client *containerd.Client, container container if len(args) != 2 { return nil, errors.New("parse logging path error") } - ioCreator = cio.TerminalBinaryIO(u.Path, map[string]string{ + parsedPath := u.Path + // For Windows, remove the leading slash + if (runtime.GOOS == "windows") && (strings.HasPrefix(parsedPath, "/")) { + parsedPath = strings.TrimLeft(parsedPath, "/") + } + ioCreator = cio.TerminalBinaryIO(parsedPath, map[string]string{ args[0]: args[1], }) } else if flagT && !flagD { @@ -108,9 +120,8 @@ func NewTask(ctx context.Context, client *containerd.Client, container container return nil, err } } - ioCreator = cio.NewCreator(cio.WithStreams(in, os.Stdout, nil), cio.WithTerminal) + ioCreator = cioutil.NewContainerIO(namespace, logURI, true, in, os.Stdout, os.Stderr) } else if flagD && logURI != "" { - // TODO: support logURI for `nerdctl run -it` u, err := url.Parse(logURI) if err != nil { return nil, err @@ -120,15 +131,15 @@ func NewTask(ctx context.Context, client *containerd.Client, container container var in io.Reader if flagI { if sv, err := infoutil.ServerSemVer(ctx, client); err != nil { - logrus.Warn(err) + log.G(ctx).Warn(err) } else if sv.LessThan(semver.MustParse("1.6.0-0")) { - logrus.Warnf("`nerdctl (run|exec) -i` without `-t` expects containerd 1.6 or later, got containerd %v", sv) + log.G(ctx).Warnf("`nerdctl (run|exec) -i` without `-t` expects containerd 1.6 or later, got containerd %v", sv) } var stdinC io.ReadCloser = &StdinCloser{ Stdin: os.Stdin, Closer: func() { if t, err := container.Task(ctx, nil); err != nil { - logrus.WithError(err).Debugf("failed to get task for StdinCloser") + log.G(ctx).WithError(err).Debugf("failed to get task for StdinCloser") } else { t.CloseIO(ctx, containerd.WithStdinCloser) } @@ -136,7 +147,7 @@ func NewTask(ctx context.Context, client *containerd.Client, container container } in = stdinC } - ioCreator = cio.NewCreator(cio.WithStreams(in, os.Stdout, os.Stderr)) + ioCreator = cioutil.NewContainerIO(namespace, logURI, false, in, os.Stdout, os.Stderr) } t, err := container.NewTask(ctx, ioCreator) if err != nil { @@ -145,6 +156,51 @@ func NewTask(ctx context.Context, client *containerd.Client, container container return t, nil } +// struct used to store streams specified with attachStreamOpt (-a, --attach) +type streams struct { + stdIn *os.File + stdOut *os.File + stdErr *os.File +} + +func nullStream() *os.File { + devNull, err := os.Open(os.DevNull) + if err != nil { + return nil + } + defer devNull.Close() + + return devNull +} + +func processAttachStreamsOpt(streamsArr []string) streams { + stdIn := os.Stdin + stdOut := os.Stdout + stdErr := os.Stderr + + for i, str := range streamsArr { + streamsArr[i] = strings.ToUpper(str) + } + + if !slices.Contains(streamsArr, "STDIN") { + stdIn = nullStream() + } + + if !slices.Contains(streamsArr, "STDOUT") { + stdOut = nullStream() + } + + if !slices.Contains(streamsArr, "STDERR") { + stdErr = nullStream() + } + + return streams{ + stdIn: stdIn, + stdOut: stdOut, + stdErr: stdErr, + } +} + // StdinCloser is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/tasks/exec.go#L181-L194 type StdinCloser struct { mu sync.Mutex diff --git a/pkg/testutil/compose.go b/pkg/testutil/compose.go index 2d1a28e17f3..2e5b55b056d 100644 --- a/pkg/testutil/compose.go +++ b/pkg/testutil/compose.go @@ -17,9 +17,13 @@ package testutil import ( + "context" "os" "path/filepath" "testing" + + "github.com/compose-spec/compose-go/v2/loader" + compose "github.com/compose-spec/compose-go/v2/types" ) type ComposeDir struct { @@ -63,3 +67,32 @@ func NewComposeDir(t testing.TB, dockerComposeYAML string) *ComposeDir { cd.WriteFile(cd.yamlBasePath, dockerComposeYAML) return cd } + +// Load is used only for unit testing. +func LoadProject(fileName, projectName string, envMap map[string]string) (*compose.Project, error) { + if envMap == nil { + envMap = make(map[string]string) + } + b, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + + wd, err := filepath.Abs(filepath.Dir(fileName)) + if err != nil { + return nil, err + } + var files []compose.ConfigFile + files = append(files, compose.ConfigFile{Filename: fileName, Content: b}) + return loader.LoadWithContext(context.TODO(), compose.ConfigDetails{ + WorkingDir: wd, + ConfigFiles: files, + Environment: envMap, + }, withProjectName(projectName)) +} + +func withProjectName(name string) func(*loader.Options) { + return func(lOpts *loader.Options) { + lOpts.SetProjectName(name, true) + } +} diff --git a/pkg/testutil/iptables/iptables_linux.go b/pkg/testutil/iptables/iptables_linux.go new file mode 100644 index 00000000000..c0dcea4d42d --- /dev/null +++ b/pkg/testutil/iptables/iptables_linux.go @@ -0,0 +1,99 @@ +/* + 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 iptables + +import ( + "fmt" + "regexp" + "testing" + + "github.com/coreos/go-iptables/iptables" +) + +// ForwardExists check that at least 2 rules are present in the CNI-HOSTPORT-DNAT chain +// and checks for regex matches in the list of rules +func ForwardExists(t *testing.T, ipt *iptables.IPTables, chain, containerIP string, port int) bool { + rules, err := ipt.List("nat", chain) + if err != nil { + t.Logf("error listing rules in chain: %q\n", err) + return false + } + + if len(rules) < 1 { + t.Logf("not enough rules: %d", len(rules)) + return false + } + + // here we check if at least one of the rules in the chain + // matches the required string to identify that the rule was applied + found := false + matchRule := `--dport ` + fmt.Sprintf("%d", port) + ` .+ --to-destination ` + containerIP + for _, rule := range rules { + foundInRule, err := regexp.MatchString(matchRule, rule) + if err != nil { + t.Logf("error in match string: %q\n", err) + return false + } + if foundInRule { + found = foundInRule + } + } + return found +} + +// GetRedirectedChain returns the chain where the traffic is being redirected. +// This is how libcni manage its port maps. +// Suppose you have the following rule: +// -A CNI-HOSTPORT-DNAT -p tcp -m comment --comment "dnat name: \"bridge\" id: \"default-YYYYYY\"" -m multiport --dports 9999 -j CNI-DN-XXXXXX +// So the chain where the traffic is redirected is CNI-DN-XXXXXX +// Returns an empty string in case nothing was found. +func GetRedirectedChain(t *testing.T, ipt *iptables.IPTables, chain, namespace, containerID string) string { + rules, err := ipt.List("nat", chain) + if err != nil { + t.Logf("error listing rules in chain: %q\n", err) + return "" + } + + if len(rules) < 1 { + t.Logf("not enough rules: %d", len(rules)) + return "" + } + + var redirectedChain string + re := regexp.MustCompile(`-j\s+([^ ]+)`) + for _, rule := range rules { + // first we verify the comment section is present: "dnat name: \"bridge\" id: \"default-YYYYYY\"" + matchesContainer, err := regexp.MatchString(namespace+"-"+containerID, rule) + if err != nil { + t.Logf("error in match string: %q\n", err) + return "" + } + if matchesContainer { + // then we find the appropriate chain in the rule + matches := re.FindStringSubmatch(rule) + fmt.Println(matches) + if len(matches) >= 2 { + redirectedChain = matches[1] + } + } + } + if redirectedChain == "" { + t.Logf("no redirectced chain found") + return "" + } + return redirectedChain +} diff --git a/pkg/testutil/nerdtest/ambient.go b/pkg/testutil/nerdtest/ambient.go new file mode 100644 index 00000000000..d59ebc6a18e --- /dev/null +++ b/pkg/testutil/nerdtest/ambient.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 nerdtest + +import "github.com/containerd/nerdctl/v2/pkg/testutil" + +func environmentHasIPv6() bool { + return testutil.GetEnableIPv6() +} + +func environmentHasKubernetes() bool { + return testutil.GetEnableKubernetes() +} + +func environmentIsForFlaky() bool { + return testutil.GetFlakyEnvironment() +} diff --git a/pkg/testutil/nerdtest/ca/ca.go b/pkg/testutil/nerdtest/ca/ca.go new file mode 100644 index 00000000000..da367d464e1 --- /dev/null +++ b/pkg/testutil/nerdtest/ca/ca.go @@ -0,0 +1,162 @@ +/* + 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 ca + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +type CA struct { + KeyPath string + CertPath string + + t *testing.T + key *rsa.PrivateKey + cert *x509.Certificate + closeF func() error +} + +func (ca *CA) Close() error { + return ca.closeF() +} + +const keyLength = 4096 + +func New(data test.Data, t *testing.T) *CA { + key, err := rsa.GenerateKey(rand.Reader, keyLength) + assert.NilError(t, err) + + cert := &x509.Certificate{ + SerialNumber: serialNumber(t), + Subject: pkix.Name{ + Organization: []string{"nerdctl test organization"}, + CommonName: fmt.Sprintf("nerdctl CA (%s)", t.Name()), + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + dir, err := os.MkdirTemp(data.TempDir(), "ca") + assert.NilError(t, err) + keyPath := filepath.Join(dir, "ca.key") + certPath := filepath.Join(dir, "ca.cert") + writePair(t, keyPath, certPath, cert, cert, key, key) + + return &CA{ + KeyPath: keyPath, + CertPath: certPath, + t: t, + key: key, + cert: cert, + closeF: func() error { + return os.RemoveAll(dir) + }, + } +} + +type Cert struct { + KeyPath string + CertPath string + closeF func() error +} + +func (c *Cert) Close() error { + return c.closeF() +} + +func (ca *CA) NewCert(host string, additional ...string) *Cert { + t := ca.t + + key, err := rsa.GenerateKey(rand.Reader, keyLength) + assert.NilError(t, err) + + additional = append([]string{host}, additional...) + + cert := &x509.Certificate{ + SerialNumber: serialNumber(t), + Subject: pkix.Name{ + Organization: []string{"nerdctl test organization"}, + CommonName: fmt.Sprintf("nerdctl %s (%s)", host, t.Name()), + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: additional, + } + for _, h := range additional { + if ip := net.ParseIP(h); ip != nil { + cert.IPAddresses = append(cert.IPAddresses, ip) + } + } + + dir, err := os.MkdirTemp(t.TempDir(), "cert") + assert.NilError(t, err) + certPath := filepath.Join(dir, "a.cert") + keyPath := filepath.Join(dir, "a.key") + writePair(t, keyPath, certPath, cert, ca.cert, key, ca.key) + + return &Cert{ + CertPath: certPath, + KeyPath: keyPath, + closeF: func() error { + return os.RemoveAll(dir) + }, + } +} + +func writePair(t *testing.T, keyPath, certPath string, cert, caCert *x509.Certificate, key, caKey *rsa.PrivateKey) { + keyF, err := os.Create(keyPath) + assert.NilError(t, err) + defer keyF.Close() + assert.NilError(t, pem.Encode(keyF, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + assert.NilError(t, keyF.Close()) + + certB, err := x509.CreateCertificate(rand.Reader, cert, caCert, &key.PublicKey, caKey) + assert.NilError(t, err) + certF, err := os.Create(certPath) + assert.NilError(t, err) + defer certF.Close() + assert.NilError(t, pem.Encode(certF, &pem.Block{Type: "CERTIFICATE", Bytes: certB})) + assert.NilError(t, certF.Close()) +} + +func serialNumber(t *testing.T) *big.Int { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 60) + sn, err := rand.Int(rand.Reader, serialNumberLimit) + assert.NilError(t, err) + return sn +} diff --git a/pkg/testutil/nerdtest/command.go b/pkg/testutil/nerdtest/command.go new file mode 100644 index 00000000000..99fb0e3b0f5 --- /dev/null +++ b/pkg/testutil/nerdtest/command.go @@ -0,0 +1,200 @@ +/* + 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 nerdtest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +const defaultNamespace = testutil.Namespace + +// IMPORTANT note on file writing here: +// Inside the context of a single test, there is no concurrency, as setup, command and cleanup operate in sequence +// Furthermore, the tempdir is private by definition. +// Writing files here in a non-safe manner is thus OK. +type target = string + +const ( + targetNerdctl = target("nerdctl") + targetDocker = target("docker") +) + +func getTarget() string { + // Indirecting to testutil for now + return testutil.GetTarget() +} + +func newNerdCommand(conf test.Config, t *testing.T) *nerdCommand { + // Decide what binary we are running + var err error + var binary string + trgt := getTarget() + switch trgt { + case targetNerdctl: + binary, err = exec.LookPath(trgt) + if err != nil { + t.Fatalf("unable to find binary %q: %v", trgt, err) + } + // Set the default namespace if we do not have something already + if conf.Read(Namespace) == "" { + conf.Write(Namespace, defaultNamespace) + } + case targetDocker: + binary, err = exec.LookPath(trgt) + if err != nil { + t.Fatalf("unable to find binary %q: %v", trgt, err) + } + if err = exec.Command("docker", "compose", "version").Run(); err != nil { + t.Fatalf("docker does not support compose: %v", err) + } + default: + t.Fatalf("unknown target %q", getTarget()) + } + + // Create the base command, with the right binary, t + ret := &nerdCommand{} + ret.WithBinary(binary) + // Not interested in these - and insulate us from parent environment side effects + ret.WithBlacklist([]string{ + "LS_COLORS", + "DOCKER_CONFIG", + "CONTAINERD_SNAPSHOTTER", + "NERDCTL_TOML", + "CONTAINERD_ADDRESS", + "CNI_PATH", + "NETCONFPATH", + "NERDCTL_EXPERIMENTAL", + "NERDCTL_HOST_GATEWAY_IP", + }) + return ret +} + +type nerdCommand struct { + test.GenericCommand + + hasWrittenToml bool + hasWrittenDockerConfig bool +} + +func (nc *nerdCommand) Run(expect *test.Expected) { + nc.prep() + if getTarget() == targetDocker { + // We are not in the business of testing docker *error* output, so, spay expectation here + if expect != nil { + expect.Errors = nil + } + } + nc.GenericCommand.Run(expect) +} + +func (nc *nerdCommand) Background(timeout time.Duration) { + nc.prep() + nc.GenericCommand.Background(timeout) +} + +// Run does override the generic command run, as we are testing both docker and nerdctl +func (nc *nerdCommand) prep() { + nc.T().Helper() + + // If no DOCKER_CONFIG was explicitly provided, set ourselves inside the current working directory + if nc.Env["DOCKER_CONFIG"] == "" { + nc.Env["DOCKER_CONFIG"] = nc.GenericCommand.TempDir + } + + 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), 0400) + assert.NilError(nc.T(), err, "failed to write custom docker config json file for test") + nc.hasWrittenDockerConfig = true + } + } + + if getTarget() == targetDocker { + // Allow debugging with docker syntax + if nc.Config.Read(Debug) != "" { + nc.PrependArgs("--log-level=debug") + } + } else if getTarget() == targetNerdctl { + // Set the namespace + if nc.Config.Read(Namespace) != "" { + nc.PrependArgs("--namespace=" + string(nc.Config.Read(Namespace))) + } + + if nc.Config.Read(stargz) == enabled { + nc.Env["CONTAINERD_SNAPSHOTTER"] = "stargz" + } + + if nc.Config.Read(ipfs) == enabled { + var ipfsPath string + if rootlessutil.IsRootless() { + var err error + ipfsPath, err = platform.DataHome() + ipfsPath = filepath.Join(ipfsPath, "ipfs") + assert.NilError(nc.T(), err) + } else { + ipfsPath = filepath.Join(os.Getenv("HOME"), ".ipfs") + } + + nc.Env["IPFS_PATH"] = ipfsPath + } + + // If no NERDCTL_TOML was explicitly provided, set it to the private dir + if nc.Env["NERDCTL_TOML"] == "" { + nc.Env["NERDCTL_TOML"] = filepath.Join(nc.GenericCommand.TempDir, "nerdctl.toml") + } + + // If we have custom toml content, write it if it does not exist already + if nc.Config.Read(NerdctlToml) != "" { + if !nc.hasWrittenToml { + dest := nc.Env["NERDCTL_TOML"] + err := os.WriteFile(dest, []byte(nc.Config.Read(NerdctlToml)), 0400) + assert.NilError(nc.T(), err, "failed to write NerdctlToml") + nc.hasWrittenToml = true + } + } + + if nc.Config.Read(HostsDir) != "" { + nc.PrependArgs("--hosts-dir=" + string(nc.Config.Read(HostsDir))) + } + if nc.Config.Read(DataRoot) != "" { + nc.PrependArgs("--data-root=" + string(nc.Config.Read(DataRoot))) + } + if nc.Config.Read(Debug) != "" { + nc.PrependArgs("--debug-full") + } + } +} + +func (nc *nerdCommand) Clone() test.TestableCommand { + return &nerdCommand{ + GenericCommand: *(nc.GenericCommand.Clone().(*test.GenericCommand)), + hasWrittenToml: nc.hasWrittenToml, + hasWrittenDockerConfig: nc.hasWrittenDockerConfig, + } +} diff --git a/pkg/testutil/nerdtest/hoststoml/hoststoml.go b/pkg/testutil/nerdtest/hoststoml/hoststoml.go new file mode 100644 index 00000000000..f6ae48b1247 --- /dev/null +++ b/pkg/testutil/nerdtest/hoststoml/hoststoml.go @@ -0,0 +1,67 @@ +/* + 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 hoststoml + +import ( + "net" + "os" + "path/filepath" + "strconv" + + "github.com/pelletier/go-toml/v2" +) + +type hostsTomlHost struct { + CA string `toml:"ca,omitempty"` + SkipVerify bool `toml:"skip_verify,omitempty"` + Client [][]string `toml:"client,omitempty"` +} + +// See https://github.com/containerd/containerd/blob/main/docs/hosts.md +type HostsToml struct { + CA string `toml:"ca,omitempty"` + SkipVerify bool `toml:"skip_verify,omitempty"` + Client [][]string `toml:"client,omitempty"` + Headers map[string]string `toml:"header,omitempty"` + Server string `toml:"server,omitempty"` + Endpoints map[string]*hostsTomlHost `toml:"host,omitempty"` +} + +func (ht *HostsToml) Save(dir string, hostIP string, port int) error { + var err error + var r *os.File + + hostSubDir := hostIP + if port != 0 { + hostSubDir = net.JoinHostPort(hostIP, strconv.Itoa(port)) + } + + hostsSubDir := filepath.Join(dir, hostSubDir) + err = os.MkdirAll(hostsSubDir, 0700) + if err != nil { + return err + } + + if r, err = os.Create(filepath.Join(dir, hostSubDir, "hosts.toml")); err == nil { + defer r.Close() + enc := toml.NewEncoder(r) + enc.SetIndentTables(true) + err = enc.Encode(ht) + } + + return err +} diff --git a/pkg/testutil/nerdtest/platform/platform_freebsd.go b/pkg/testutil/nerdtest/platform/platform_freebsd.go new file mode 100644 index 00000000000..8128c930167 --- /dev/null +++ b/pkg/testutil/nerdtest/platform/platform_freebsd.go @@ -0,0 +1,29 @@ +/* + 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 platform + +func DataHome() (string, error) { + panic("not supported") +} + +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 new file mode 100644 index 00000000000..3aeeb0f03c8 --- /dev/null +++ b/pkg/testutil/nerdtest/platform/platform_linux.go @@ -0,0 +1,33 @@ +/* + 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 platform + +import ( + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +func DataHome() (string, error) { + return rootlessutil.XDGDataHome() +} + +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 new file mode 100644 index 00000000000..56be8501931 --- /dev/null +++ b/pkg/testutil/nerdtest/platform/platform_windows.go @@ -0,0 +1,37 @@ +/* + 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 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") +) diff --git a/pkg/testutil/nerdtest/registry/cesanta.go b/pkg/testutil/nerdtest/registry/cesanta.go new file mode 100644 index 00000000000..cf074542c35 --- /dev/null +++ b/pkg/testutil/nerdtest/registry/cesanta.go @@ -0,0 +1,237 @@ +/* + 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 registry + +import ( + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/ca" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +type CesantaConfigServer struct { + Addr string `yaml:"addr,omitempty"` + Certificate string + Key string +} + +type CesantaConfigToken struct { + Issuer string `yaml:"issuer,omitempty"` + Certificate string `yaml:"certificate,omitempty"` + Key string `yaml:"key,omitempty"` + Expiration int `yaml:"expiration,omitempty"` +} + +type CesantaConfigUser struct { + Password string `yaml:"password,omitempty"` +} + +type CesantaMatchConditions struct { + Account string `yaml:"account,omitempty"` +} + +type CesantaConfigACLEntry struct { + Match CesantaMatchConditions `yaml:"match"` + Actions []string `yaml:"actions,flow"` +} + +type CesantaConfigACL []CesantaConfigACLEntry + +type CesantaConfig struct { + Server CesantaConfigServer `yaml:"server"` + Token CesantaConfigToken `yaml:"token"` + Users map[string]CesantaConfigUser `yaml:"users,omitempty"` + ACL CesantaConfigACL `yaml:"acl,omitempty"` +} + +func (cc *CesantaConfig) Save(path string) error { + var err error + var r *os.File + if r, err = os.Create(path); err == nil { + defer r.Close() + err = yaml.NewEncoder(r).Encode(cc) + } + return err +} + +// FIXME: this is a copy of the utility method EnsureContainerStarted +// We cannot reference it (circular dep), so the copy. +// To be fixed later when we will be done migrating test helpers to the new framework and we can split them +// in meaningful subpackages. + +func ensureContainerStarted(helpers test.Helpers, con string) { + started := false + for i := 0; i < 5 && !started; i++ { + helpers.Command("container", "inspect", con). + Run(&test.Expected{ + ExitCode: test.ExitCodeNoCheck, + Output: func(stdout string, info string, t *testing.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) + started = dc[0].State.Running + }, + }) + time.Sleep(time.Second) + } + + if !started { + 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().Fatalf("container %s still not running after %d retries", con, 5) + } +} + +func NewCesantaAuthServer(data test.Data, helpers test.Helpers, ca *ca.CA, port int, user, pass string, tls bool) *TokenAuthServer { + // listen on 0.0.0.0 to enable 127.0.0.1 + listenIP := net.ParseIP("0.0.0.0") + hostIP, err := nettestutil.NonLoopbackIPv4() + assert.NilError(helpers.T(), err, fmt.Errorf("failed finding ipv4 non loopback interface: %w", err)) + bpass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + assert.NilError(helpers.T(), err, fmt.Errorf("failed bcrypt encrypting password: %w", err)) + // Prepare configuration file for authentication server + // Details: https://github.com/cesanta/docker_auth/blob/1.7.1/examples/simple.yml + configFile, err := os.CreateTemp(data.TempDir(), "authconfig") + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating temporary directory for config file: %w", err)) + configFileName := configFile.Name() + + cc := &CesantaConfig{ + Server: CesantaConfigServer{ + Addr: ":5100", + }, + Token: CesantaConfigToken{ + Issuer: "Cesanta auth server", + Expiration: 900, + }, + Users: map[string]CesantaConfigUser{ + user: { + Password: string(bpass), + }, + }, + ACL: CesantaConfigACL{ + { + Match: CesantaMatchConditions{ + Account: user, + }, + Actions: []string{"*"}, + }, + }, + } + + scheme := "http" + if tls { + scheme = "https" + cc.Server.Certificate = "/auth/domain.crt" + cc.Server.Key = "/auth/domain.key" + } else { + cc.Token.Certificate = "/auth/domain.crt" + cc.Token.Key = "/auth/domain.key" + } + + err = cc.Save(configFileName) + assert.NilError(helpers.T(), err, fmt.Errorf("failed writing configuration: %w", err)) + + cert := ca.NewCert(hostIP.String()) + // FIXME: this will fail in many circumstances. Review strategy on how to acquire a free port. + // We probably have better code for that already somewhere. + port, err = portlock.Acquire(port) + assert.NilError(helpers.T(), err, fmt.Errorf("failed acquiring port: %w", err)) + containerName := data.Identifier(fmt.Sprintf("cesanta-auth-server-%d-%t", port, tls)) + // Cleanup possible leftovers first + helpers.Ensure("rm", "-f", containerName) + + cleanup := func(data test.Data, helpers test.Helpers) { + helpers.Ensure("rm", "-f", containerName) + errPortRelease := portlock.Release(port) + errCertClose := cert.Close() + errConfigClose := configFile.Close() + errConfigRemove := os.Remove(configFileName) + if errPortRelease != nil { + helpers.T().Error(errPortRelease.Error()) + } + if errCertClose != nil { + helpers.T().Error(errCertClose.Error()) + } + if errConfigClose != nil { + helpers.T().Error(errConfigClose.Error()) + } + if errConfigRemove != nil { + helpers.T().Error(errConfigRemove.Error()) + } + } + + setup := func(data test.Data, helpers test.Helpers) { + helpers.Ensure( + "run", + "--pull=never", + "-d", + "-p", fmt.Sprintf("%s:%d:5100", listenIP, port), + "--name", containerName, + "-v", cert.CertPath+":/auth/domain.crt", + "-v", cert.KeyPath+":/auth/domain.key", + "-v", configFileName+":/config/auth_config.yml", + platform.DockerAuthImage, + "/config/auth_config.yml", + ) + ensureContainerStarted(helpers, containerName) + _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/auth", + scheme, + net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), + ), + 10, + true) + assert.NilError(helpers.T(), err, fmt.Errorf("failed starting auth container in a timely manner: %w", err)) + + } + + return &TokenAuthServer{ + IP: hostIP, + Port: port, + Scheme: scheme, + CertPath: cert.CertPath, + Auth: &TokenAuth{ + Address: scheme + "://" + net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), + CertPath: cert.CertPath, + }, + Setup: setup, + Cleanup: cleanup, + Logs: func(data test.Data, helpers test.Helpers) { + helpers.T().Error(helpers.Err("logs", containerName)) + }, + } +} diff --git a/pkg/testutil/nerdtest/registry/common.go b/pkg/testutil/nerdtest/registry/common.go new file mode 100644 index 00000000000..0f7496b049a --- /dev/null +++ b/pkg/testutil/nerdtest/registry/common.go @@ -0,0 +1,108 @@ +/* + 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 registry + +import ( + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/bcrypt" + + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +// Auth describes a struct able to serialize authenticator information into arguments to be fed to a registry container run +type Auth interface { + Params(data test.Data) []string +} + +type NoAuth struct { +} + +func (na *NoAuth) Params(data test.Data) []string { + return []string{} +} + +type TokenAuth struct { + Address string + CertPath string +} + +// FIXME: this is specific to Docker Registry +// Like need something else for Harbor and Gitlab +func (ta *TokenAuth) Params(data test.Data) []string { + return []string{ + "--env", "REGISTRY_AUTH=token", + "--env", "REGISTRY_AUTH_TOKEN_REALM=" + ta.Address + "/auth", + "--env", "REGISTRY_AUTH_TOKEN_SERVICE=Docker registry", + "--env", "REGISTRY_AUTH_TOKEN_ISSUER=Cesanta auth server", + "--env", "REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/auth/domain.crt", + "-v", ta.CertPath + ":/auth/domain.crt", + } +} + +type BasicAuth struct { + Realm string + HtFile string + Username string + Password string +} + +func (ba *BasicAuth) Params(data test.Data) []string { + if ba.Realm == "" { + ba.Realm = "Basic Realm" + } + if ba.HtFile == "" && ba.Username != "" && ba.Password != "" { + pass := ba.Password + encryptedPass, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + tmpDir, _ := os.MkdirTemp(data.TempDir(), "htpasswd") + ba.HtFile = filepath.Join(tmpDir, "htpasswd") + _ = os.WriteFile(ba.HtFile, []byte(fmt.Sprintf(`%s:%s`, ba.Username, string(encryptedPass[:]))), 0600) + } + ret := []string{ + "--env", "REGISTRY_AUTH=htpasswd", + "--env", "REGISTRY_AUTH_HTPASSWD_REALM=" + ba.Realm, + "--env", "REGISTRY_AUTH_HTPASSWD_PATH=/htpasswd", + } + if ba.HtFile != "" { + ret = append(ret, "-v", ba.HtFile+":/htpasswd") + } + return ret +} + +type TokenAuthServer struct { + Scheme string + IP net.IP + Port int + CertPath string + Cleanup func(data test.Data, helpers test.Helpers) + Setup func(data test.Data, helpers test.Helpers) + Logs func(data test.Data, helpers test.Helpers) + Auth Auth +} + +type Server struct { + Scheme string + IP net.IP + Port int + Cleanup func(data test.Data, helpers test.Helpers) + Setup func(data test.Data, helpers test.Helpers) + Logs func(data test.Data, helpers test.Helpers) + HostsDir string // contains ":/hosts.toml" +} diff --git a/pkg/testutil/nerdtest/registry/docker.go b/pkg/testutil/nerdtest/registry/docker.go new file mode 100644 index 00000000000..9248dc56652 --- /dev/null +++ b/pkg/testutil/nerdtest/registry/docker.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 registry + +import ( + "fmt" + "net" + "os" + "strconv" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/ca" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/hoststoml" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func NewDockerRegistry(data test.Data, helpers test.Helpers, currentCA *ca.CA, port int, auth Auth) *Server { + // listen on 0.0.0.0 to enable 127.0.0.1 + listenIP := net.ParseIP("0.0.0.0") + hostIP, err := nettestutil.NonLoopbackIPv4() + assert.NilError(helpers.T(), err, fmt.Errorf("failed finding ipv4 non loopback interface: %w", err)) + // XXX RELEASE PORT IN CLEANUP HERE + // FIXME: this will fail in many circumstances. Review strategy on how to acquire a free port. + // We probably have better code for that already somewhere. + port, err = portlock.Acquire(port) + assert.NilError(helpers.T(), err, fmt.Errorf("failed acquiring port: %w", err)) + + containerName := data.Identifier(fmt.Sprintf("docker-registry-server-%d-%t", port, currentCA != nil)) + // Cleanup possible leftovers first + helpers.Ensure("rm", "-f", containerName) + + args := []string{ + "run", + "--pull=never", + "-d", + "-p", fmt.Sprintf("%s:%d:5000", listenIP, port), + "--name", containerName, + } + scheme := "http" + var cert *ca.Cert + if currentCA != nil { + scheme = "https" + cert = currentCA.NewCert(hostIP.String(), "127.0.0.1", "localhost", "::1") + args = append(args, + "--env", "REGISTRY_HTTP_TLS_CERTIFICATE=/registry/domain.crt", + "--env", "REGISTRY_HTTP_TLS_KEY=/registry/domain.key", + "-v", cert.CertPath+":/registry/domain.crt", + "-v", cert.KeyPath+":/registry/domain.key", + ) + } + + // 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) { + helpers.Anyhow("rm", "-f", containerName) + errPortRelease := portlock.Release(port) + + if cert != nil { + assert.NilError(helpers.T(), cert.Close(), fmt.Errorf("failed cleaning certificates: %w", err)) + } + + assert.NilError(helpers.T(), errPortRelease, fmt.Errorf("failed releasing port: %w", err)) + } + + // FIXME: in the future, we will want to further manipulate hosts toml file from the test + // This should then return the struct, instead of saving it on its own + hostsDir, err := func() (string, error) { + hDir, err := os.MkdirTemp(data.TempDir(), "certs.d") + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating directory certs.d: %w", err)) + + if currentCA != nil { + hostTomlContent := &hoststoml.HostsToml{ + CA: currentCA.CertPath, + } + + err = hostTomlContent.Save(hDir, hostIP.String(), port) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + err = hostTomlContent.Save(hDir, "127.0.0.1", port) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + err = hostTomlContent.Save(hDir, "localhost", port) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + if port == 443 { + err = hostTomlContent.Save(hDir, hostIP.String(), 0) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + err = hostTomlContent.Save(hDir, "127.0.0.1", 0) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + err = hostTomlContent.Save(hDir, "localhost", 0) + assert.NilError(helpers.T(), err, fmt.Errorf("failed creating hosts.toml file: %w", err)) + + } + } + + return hDir, nil + }() + + setup := func(data test.Data, helpers test.Helpers) { + helpers.Ensure(args...) + ensureContainerStarted(helpers, containerName) + _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/v2/", + scheme, + net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), + ), + 10, + true) + assert.NilError(helpers.T(), err, fmt.Errorf("failed starting docker registry in a timely manner: %w", err)) + } + + return &Server{ + Scheme: scheme, + IP: hostIP, + Port: port, + Cleanup: cleanup, + Setup: setup, + Logs: func(data test.Data, helpers test.Helpers) { + helpers.T().Error(helpers.Err("logs", containerName)) + }, + HostsDir: hostsDir, + } +} diff --git a/pkg/testutil/nerdtest/registry/kubo.go b/pkg/testutil/nerdtest/registry/kubo.go new file mode 100644 index 00000000000..8cb350a65b0 --- /dev/null +++ b/pkg/testutil/nerdtest/registry/kubo.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 registry + +import ( + "fmt" + "net" + "strconv" + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/ca" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func NewKuboRegistry(data test.Data, helpers test.Helpers, t *testing.T, currentCA *ca.CA, port int, auth Auth) *Server { + // listen on 0.0.0.0 to enable 127.0.0.1 + listenIP := net.ParseIP("0.0.0.0") + hostIP, err := nettestutil.NonLoopbackIPv4() + assert.NilError(t, err, fmt.Errorf("failed finding ipv4 non loopback interface: %w", err)) + port, err = portlock.Acquire(port) + assert.NilError(t, err, fmt.Errorf("failed acquiring port: %w", err)) + + containerName := data.Identifier(fmt.Sprintf("kubo-registry-server-%d-%t", port, currentCA != nil)) + // Cleanup possible leftovers first + helpers.Ensure("rm", "-f", containerName) + + args := []string{ + "run", + "--pull=never", + "-d", + "-p", fmt.Sprintf("%s:%d:%d", listenIP, port, port), + "--name", containerName, + "--entrypoint=/bin/sh", + platform.KuboImage, + "-c", "--", + fmt.Sprintf("ipfs init && ipfs config Addresses.API /ip4/0.0.0.0/tcp/%d && ipfs daemon --offline", port), + } + + scheme := "http" + + cleanup := func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", containerName) + errPortRelease := portlock.Release(port) + + assert.NilError(t, errPortRelease, fmt.Errorf("failed releasing port: %w", err)) + } + + setup := func(data test.Data, helpers test.Helpers) { + helpers.Ensure(args...) + ensureContainerStarted(helpers, containerName) + _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/api/v0", + scheme, + net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), + ), + 30, + 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)) + } + + return &Server{ + IP: hostIP, + Port: port, + Scheme: scheme, + Cleanup: cleanup, + Setup: setup, + Logs: func(data test.Data, helpers test.Helpers) { + helpers.T().Error(helpers.Err("logs", containerName)) + }, + } +} diff --git a/pkg/testutil/nerdtest/requirements.go b/pkg/testutil/nerdtest/requirements.go new file mode 100644 index 00000000000..a5d89481203 --- /dev/null +++ b/pkg/testutil/nerdtest/requirements.go @@ -0,0 +1,338 @@ +/* + 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 nerdtest + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +var BuildkitHost test.ConfigKey = "BuildkitHost" + +// These are used for ambient requirements +var ipv6 test.ConfigKey = "IPv6Test" +var kubernetes test.ConfigKey = "KubeTest" +var flaky test.ConfigKey = "FlakyTest" +var only test.ConfigValue = "Only" + +// These are used for down the road configuration and custom behavior inside command +var modePrivate test.ConfigKey = "PrivateMode" +var stargz test.ConfigKey = "Stargz" +var ipfs test.ConfigKey = "IPFS" +var enabled test.ConfigValue = "Enabled" + +// OnlyIPv6 marks a test as suitable to be run exclusively inside an ipv6 environment +// This is an ambient requirement +var OnlyIPv6 = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + helpers.Write(ipv6, only) + ret = environmentHasIPv6() + if !ret { + mess = "runner skips IPv6 compatible tests in the non-IPv6 environment" + } + return ret, mess + }, +} + +// OnlyKubernetes marks a test as meant to be tested on Kubernetes +// This is an ambient requirement +var OnlyKubernetes = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + helpers.Write(kubernetes, only) + if _, err := exec.LookPath("kubectl"); err != nil { + return false, fmt.Sprintf("kubectl is not in the path: %+v", err) + } + ret = environmentHasKubernetes() + if ret { + helpers.Write(Namespace, "k8s.io") + } else { + mess = "runner skips Kubernetes compatible tests in the non-Kubernetes environment" + } + return ret, mess + }, +} + +// IsFlaky marks a test as randomly failing. +// This is an ambient requirement +var IsFlaky = func(issueLink string) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + // We do not even want to get to the setup phase here + helpers.Write(flaky, only) + ret = environmentIsForFlaky() + if !ret { + mess = "runner skips flaky compatible tests in the non-flaky environment" + } + return ret, mess + }, + } +} + +// Docker marks a test as suitable solely for Docker and not Nerdctl +// Generally used as test.Not(nerdtest.Docker), which of course it the opposite +var Docker = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = getTarget() == targetDocker + if ret { + mess = "current target is docker" + } else { + mess = "current target is not docker" + } + return ret, mess + }, +} + +// NerdctlNeedsFixing marks a test as unsuitable to be run for Nerdctl, because of a specific known issue which +// url must be passed as an argument +var NerdctlNeedsFixing = func(issueLink string) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = getTarget() == targetDocker + if ret { + mess = "current target is docker" + } else { + mess = "current target is nerdctl, but we will skip as nerdctl currently has issue: " + issueLink + } + return ret, mess + }, + } +} + +// BrokenTest marks a test as currently broken, with explanation provided in message, along with +// additional requirements / restrictions describing what it can run on. +var BrokenTest = func(message string, req *test.Requirement) *test.Requirement { + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + ret, mess := req.Check(data, helpers) + return ret, message + "\n" + mess + }, + Setup: req.Setup, + Cleanup: req.Cleanup, + } +} + +// Rootless marks a test as suitable only for the rootless environment +var Rootless = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + // Make sure we DO not return "IsRootless true" for docker + ret = getTarget() == targetNerdctl && rootlessutil.IsRootless() + if ret { + mess = "environment is root-less" + } else { + mess = "environment is root-ful" + } + return ret, mess + }, +} + +// Rootful marks a test as suitable only for rootful env +var Rootful = test.Not(Rootless) + +// CGroup requires that cgroup is enabled +var CGroup = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = true + mess = "cgroup is enabled" + stdout := helpers.Capture("info", "--format", "{{ json . }}") + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + assert.NilError(helpers.T(), err, "failed to parse docker info") + switch dinf.CgroupDriver { + case "none", "": + ret = false + mess = "cgroup is none" + } + return ret, mess + }, +} + +var CgroupsAccessible = test.Require( + CGroup, + &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + isRootLess := getTarget() == targetNerdctl && rootlessutil.IsRootless() + if isRootLess { + stdout := helpers.Capture("info", "--format", "{{ json . }}") + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + assert.NilError(helpers.T(), err, "failed to parse docker info") + return dinf.CgroupVersion == "2", "we are rootless, and cgroup version is not 2" + } + return true, "" + }, + }, +) + +// Soci requires that the soci snapshotter is enabled +var Soci = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = false + mess = "soci is not enabled" + stdout := helpers.Capture("info", "--format", "{{ json . }}") + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + assert.NilError(helpers.T(), err, "failed to parse docker info") + for _, p := range dinf.Plugins.Storage { + if p == "soci" { + ret = true + mess = "soci is enabled" + } + } + return ret, mess + }, +} + +var Stargz = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = false + mess = "stargz is not enabled" + stdout := helpers.Capture("info", "--format", "{{ json . }}") + var dinf dockercompat.Info + err := json.Unmarshal([]byte(stdout), &dinf) + assert.NilError(helpers.T(), err, "failed to parse docker info") + for _, p := range dinf.Plugins.Storage { + if p == "stargz" { + ret = true + mess = "stargz is enabled" + } + } + // Need this to happen now for Cleanups to work + // FIXME: we should be able to access the env (at least through helpers.Command().) instead of this gym + helpers.Write(stargz, enabled) + return ret, mess + }, +} + +// Registry marks a test as requiring a registry to be deployed +var Registry = test.Require( + // Registry requires Linux currently + test.Linux, + (func() *test.Requirement { + // Provisional: see note in cleanup + // var reg *registry.Server + + return &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + return true, "" + }, + Setup: func(data test.Data, helpers test.Helpers) { + // Ensure we have registry images now, so that we can run --pull=never + // This is useful for two reasons: + // - if ghcr.io is out, we want to fail early + // - 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) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // FIXME: figure out what to do with reg setup/cleanup routines + // Provisionally, reg is available here in the closure + }, + } + })(), +) + +// Build marks a test as suitable only if buildkitd is enabled (only tested for nerdctl obviously) +var Build = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (bool, string) { + // FIXME: shouldn't we run buildkitd in a container? At least for testing, that would be so much easier than + // against the host install + ret := true + mess := "buildkitd is enabled" + + if getTarget() == targetNerdctl { + bkHostAddr, err := buildkitutil.GetBuildkitHost(defaultNamespace) + if err != nil { + ret = false + mess = fmt.Sprintf("buildkitd is not enabled: %+v", err) + return ret, mess + } + // We also require the buildctl binary in the path + _, err = exec.LookPath("buildctl") + if err != nil { + ret = false + mess = fmt.Sprintf("buildctl is not in the path: %+v", err) + return ret, mess + } + helpers.Write(BuildkitHost, test.ConfigValue(bkHostAddr)) + } + return ret, mess + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + // Previously, every build test was sequential, and was purging the build cache. + // Running this in parallel of any other test depending on build will trash it. + // The only way to parallelize any test involving build is indeed to disable this. + // The price to pay is that we might get cache from another test. + // This can be avoided individually by passing --no-cache if and when necessary + // helpers.Anyhow("builder", "prune", "--all", "--force") + }, +} + +var IPFS = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + // FIXME: we should be able to access the env (at least through helpers.Command().) instead of this gym + helpers.Write(ipfs, enabled) + // FIXME: this is incomplete. We obviously need a daemon running, properly configured + return test.Binary("ipfs").Check(data, helpers) + }, +} + +// Private makes a test run inside a dedicated namespace, with a private config.toml, hosts directory, and DOCKER_CONFIG path +// If the target is docker, parallelism is forcefully disabled +var Private = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + // We need this to happen NOW and not in setup, as otherwise cleanup with operate on the default namespace + namespace := data.Identifier("private") + helpers.Write(Namespace, test.ConfigValue(namespace)) + data.Set("_deletenamespace", namespace) + // FIXME: is this necessary? Should NoParallel be subsumed into config? + helpers.Write(modePrivate, enabled) + return true, "private mode creates a dedicated namespace for nerdctl, and disable parallelism for docker" + }, + + Cleanup: func(data test.Data, helpers test.Helpers) { + if getTarget() == targetNerdctl { + // FIXME: there are conditions where we still have some stuff in there and this fails... + containerList := strings.TrimSpace(helpers.Capture("ps", "-aq")) + if containerList != "" { + helpers.Ensure(append([]string{"rm", "-f"}, strings.Split(containerList, "\n")...)...) + } + helpers.Ensure("system", "prune", "-f", "--all", "--volumes") + helpers.Anyhow("namespace", "remove", data.Get("_deletenamespace")) + } + }, +} diff --git a/pkg/testutil/nerdtest/requirements_other.go b/pkg/testutil/nerdtest/requirements_other.go new file mode 100644 index 00000000000..74e280670c7 --- /dev/null +++ b/pkg/testutil/nerdtest/requirements_other.go @@ -0,0 +1,29 @@ +//go:build !windows + +/* + 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 nerdtest + +import ( + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +var HyperV = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + return false, "HyperV is a windows-only feature" + }, +} diff --git a/pkg/testutil/nerdtest/requirements_windows.go b/pkg/testutil/nerdtest/requirements_windows.go new file mode 100644 index 00000000000..a1155a970db --- /dev/null +++ b/pkg/testutil/nerdtest/requirements_windows.go @@ -0,0 +1,28 @@ +/* + 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 nerdtest + +import ( + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +var HyperV = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + return testutil.HyperVSupported(), "HyperV is not enabled, skipping test" + }, +} diff --git a/pkg/testutil/nerdtest/test.go b/pkg/testutil/nerdtest/test.go new file mode 100644 index 00000000000..7bfd485cdf2 --- /dev/null +++ b/pkg/testutil/nerdtest/test.go @@ -0,0 +1,67 @@ +/* + 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 nerdtest + +import ( + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +var DockerConfig test.ConfigKey = "DockerConfig" +var Namespace test.ConfigKey = "Namespace" +var NerdctlToml test.ConfigKey = "NerdctlToml" +var HostsDir test.ConfigKey = "HostsDir" +var DataRoot test.ConfigKey = "DataRoot" +var Debug test.ConfigKey = "Debug" + +func Setup() *test.Case { + test.Customize(&nerdctlSetup{}) + return &test.Case{ + Env: map[string]string{}, + } +} + +type nerdctlSetup struct { +} + +func (ns *nerdctlSetup) CustomCommand(testCase *test.Case, t *testing.T) test.CustomizableCommand { + return newNerdCommand(testCase.Config, t) +} + +func (ns *nerdctlSetup) AmbientRequirements(testCase *test.Case, t *testing.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") + } + + if environmentHasKubernetes() && testCase.Config.Read(kubernetes) != only { + t.Skip("runner skips non-Kubernetes compatible tests in the Kubernetes environment") + } + + if environmentIsForFlaky() && testCase.Config.Read(flaky) != only { + t.Skip("runner skips non-flaky tests in the flaky environment") + } + + if getTarget() == targetDocker && testCase.Config.Read(modePrivate) == enabled { + // For docker, we do disable parallel since there is no namespace where we can isolate + testCase.NoParallel = true + } + + // We do not want private to get inherited by subtests, as we want them to be in the same namespace set here + testCase.Config.Write(modePrivate, "") +} diff --git a/pkg/testutil/nerdtest/third-party.go b/pkg/testutil/nerdtest/third-party.go new file mode 100644 index 00000000000..087ed50ed78 --- /dev/null +++ b/pkg/testutil/nerdtest/third-party.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 nerdtest + +import ( + "os/exec" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/ca" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +func BuildCtlCommand(helpers test.Helpers, args ...string) test.TestableCommand { + assert.Assert(helpers.T(), string(helpers.Read(BuildkitHost)) != "", "You first need to Require Build to use buildctl") + buildctl, _ := exec.LookPath("buildctl") + cmd := helpers.Custom(buildctl) + cmd.WithArgs("--addr=" + string(helpers.Read(BuildkitHost))) + cmd.WithArgs(args...) + return cmd +} + +func KubeCtlCommand(helpers test.Helpers, args ...string) test.TestableCommand { + kubectl, _ := exec.LookPath("kubectl") + cmd := helpers.Custom(kubectl) + cmd.WithArgs("--namespace=nerdctl-test-k8s") + cmd.WithArgs(args...) + return cmd +} + +func RegistryWithTokenAuth(data test.Data, helpers test.Helpers, user, pass string, port int, tls bool) (*registry.Server, *registry.TokenAuthServer) { + rca := ca.New(data, helpers.T()) + as := registry.NewCesantaAuthServer(data, helpers, rca, 0, user, pass, tls) + re := registry.NewDockerRegistry(data, helpers, rca, port, as.Auth) + return re, as +} + +func RegistryWithNoAuth(data test.Data, helpers test.Helpers, port int, tls bool) *registry.Server { + var rca *ca.CA + if tls { + rca = ca.New(data, helpers.T()) + } + return registry.NewDockerRegistry(data, helpers, rca, port, ®istry.NoAuth{}) +} + +func RegistryWithBasicAuth(data test.Data, helpers test.Helpers, user, pass string, port int, tls bool) *registry.Server { + auth := ®istry.BasicAuth{ + Username: user, + Password: pass, + } + var rca *ca.CA + if tls { + rca = ca.New(data, helpers.T()) + } + return registry.NewDockerRegistry(data, helpers, rca, port, auth) +} diff --git a/pkg/testutil/nerdtest/utilities.go b/pkg/testutil/nerdtest/utilities.go new file mode 100644 index 00000000000..18f34a8af7c --- /dev/null +++ b/pkg/testutil/nerdtest/utilities.go @@ -0,0 +1,130 @@ +/* + 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 nerdtest + +import ( + "encoding/json" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/native" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/test" +) + +const ( + // It seems that at this moment, the busybox on windows image we are using has an outdated version of sleep + // that does not support inf/infinity. + // This constant is provided as a mean for tests to express the intention of sleep infinity without having to + // worry about that and get windows compatibility. + Infinity = "3600" +) + +func IsDocker() bool { + return testutil.GetTarget() == "docker" +} + +// InspectContainer is a helper that can be used inside custom commands or Setup +func InspectContainer(helpers test.Helpers, name string) dockercompat.Container { + var dc []dockercompat.Container + cmd := helpers.Command("container", "inspect", name) + cmd.Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + 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) + }, + }) + return dc[0] +} + +func InspectVolume(helpers test.Helpers, name string) native.Volume { + var dc []native.Volume + cmd := helpers.Command("volume", "inspect", name) + cmd.Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + 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) + }, + }) + return dc[0] +} + +func InspectNetwork(helpers test.Helpers, name string) dockercompat.Network { + var dc []dockercompat.Network + cmd := helpers.Command("network", "inspect", name) + cmd.Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + 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) + }, + }) + return dc[0] +} + +func InspectImage(helpers test.Helpers, name string) dockercompat.Image { + var dc []dockercompat.Image + cmd := helpers.Command("image", "inspect", name) + cmd.Run(&test.Expected{ + Output: func(stdout string, info string, t *testing.T) { + 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) + }, + }) + return dc[0] +} + +const ( + maxRetry = 10 + sleep = time.Second +) + +func EnsureContainerStarted(helpers test.Helpers, con string) { + started := false + for i := 0; i < maxRetry && !started; i++ { + helpers.Command("container", "inspect", con). + Run(&test.Expected{ + ExitCode: test.ExitCodeNoCheck, + Output: func(stdout string, info string, t *testing.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) + started = dc[0].State.Running + }, + }) + time.Sleep(sleep) + } + + if !started { + 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().Fatalf("container %s still not running after %d retries", con, maxRetry) + } +} diff --git a/pkg/testutil/nettestutil/nettestutil.go b/pkg/testutil/nettestutil/nettestutil.go index 960d7738bca..4937b8acc21 100644 --- a/pkg/testutil/nettestutil/nettestutil.go +++ b/pkg/testutil/nettestutil/nettestutil.go @@ -24,7 +24,7 @@ import ( "net/http" "time" - "github.com/containerd/containerd/errdefs" + "github.com/containerd/errdefs" ) func HTTPGet(urlStr string, attempts int, insecure bool) (*http.Response, error) { @@ -54,6 +54,7 @@ func HTTPGet(urlStr string, attempts int, insecure bool) (*http.Response, error) } func NonLoopbackIPv4() (net.IP, error) { + // no need to use [rootlessutil.WithDetachedNetNSIfAny] here addrs, err := net.InterfaceAddrs() if err != nil { return nil, err diff --git a/pkg/testutil/portlock/portlock.go b/pkg/testutil/portlock/portlock.go new file mode 100644 index 00000000000..5e1b5bcacee --- /dev/null +++ b/pkg/testutil/portlock/portlock.go @@ -0,0 +1,64 @@ +/* + 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. +*/ + +// portlock provides a mechanism for containers to acquire and release ports they plan to expose, and a wait mechanism +// This allows tests dependent on running containers to always parallelize without having to worry about port collision +// with any other test +// Note that this does NOT protect against trying to use a port that is already used by an unrelated third-party service or container +// Also note that *generally* finding a free port is not easy: +// - to just "listen" and see if it works won't work for containerized services that are DNAT-ed (plus, that would be racy) +// - inspecting iptables instead (or in addition to) may work for containers, but this depends on how networking has been set (and yes, it is also racy) +// Our approach here is optimistic: tests are responsible for calling Acquire and Release +package portlock + +import ( + "fmt" + "sync" + "time" +) + +var mut = &sync.Mutex{} //nolint:gochecknoglobals +var portList = map[int]bool{} + +func Acquire(port int) (int, error) { + flexible := false + if port == 0 { + port = 5000 + flexible = true + } + for { + mut.Lock() + if _, ok := portList[port]; !ok { + portList[port] = true + mut.Unlock() + return port, nil + } + mut.Unlock() + if flexible { + port++ + continue + } + fmt.Println("Waiting for port to become available...", port) + time.Sleep(1 * time.Second) + } +} + +func Release(port int) error { + mut.Lock() + delete(portList, port) + mut.Unlock() + return nil +} diff --git a/pkg/testutil/test/case.go b/pkg/testutil/test/case.go new file mode 100644 index 00000000000..58f9f573757 --- /dev/null +++ b/pkg/testutil/test/case.go @@ -0,0 +1,197 @@ +/* + 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 test + +import ( + "slices" + "testing" + + "gotest.tools/v3/assert" +) + +// Case describes an entire test-case, including data, setup and cleanup routines, command and expectations +type Case struct { + // Description contains a human-readable short desc, used as a seed for the identifier and as a title for the test + Description string + // NoParallel disables parallel execution if set to true + // This obviously implies that all tests run in parallel, by default. This is a design choice. + NoParallel bool + // Env contains a map of environment variables to use as a base for all commands run in Setup, Command and Cleanup + // Note that the environment is inherited by subtests + Env map[string]string + // Data contains test specific data, accessible to all operations, also inherited by subtests + Data Data + // Config contains specific information meaningful to the binary being tested. + // It is also inherited by subtests + Config Config + + // Requirement + Require *Requirement + // Setup + Setup Butler + // Command + Command Executor + // Expected + Expected Manager + // Cleanup + Cleanup Butler + + // SubTests + SubTests []*Case + + // Private + helpers Helpers + t *testing.T + parent *Case +} + +// Run prepares and executes the test, and any possible subtests +func (test *Case) Run(t *testing.T) { + t.Helper() + // Run the test + testRun := func(subT *testing.T) { + subT.Helper() + + assert.Assert(subT, test.t == nil, "You cannot run a test multiple times") + + // Attach testing.T + test.t = subT + assert.Assert(test.t, test.Description != "" || test.parent == nil, "A test description cannot be empty") + assert.Assert(test.t, test.Command == nil || test.Expected != nil, + "Expectations for a test command cannot be nil. You may want to use Setup instead.") + + // Ensure we have env + if test.Env == nil { + test.Env = map[string]string{} + } + + // If we have a parent, get parent env, data and config + var parentData Data + var parentConfig Config + if test.parent != nil { + parentData = test.parent.Data + parentConfig = test.parent.Config + for k, v := range test.parent.Env { + if _, ok := test.Env[k]; !ok { + test.Env[k] = v + } + } + } + + // Inherit and attach Data and Config + test.Data = configureData(test.t, test.Data, parentData) + test.Config = configureConfig(test.Config, parentConfig) + + var b CustomizableCommand + if registeredTestable == nil { + b = &GenericCommand{} + } else { + b = registeredTestable.CustomCommand(test, test.t) + } + + b.WithCwd(test.Data.TempDir()) + + b.withT(test.t) + b.withTempDir(test.Data.TempDir()) + b.withEnv(test.Env) + b.withConfig(test.Config) + + // Attach the base command, and t + test.helpers = &helpersInternal{ + cmdInternal: b, + t: test.t, + } + + setups := []func(data Data, helpers Helpers){} + cleanups := []func(data Data, helpers Helpers){} + + // Check the requirements before going any further + if test.Require != nil { + shouldRun, message := test.Require.Check(test.Data, test.helpers) + if !shouldRun { + test.t.Skipf("test skipped as: %s", message) + } + if test.Require.Setup != nil { + setups = append(setups, test.Require.Setup) + } + if test.Require.Cleanup != nil { + cleanups = append(cleanups, test.Require.Cleanup) + } + } + + // Register setup if any + if test.Setup != nil { + setups = append(setups, test.Setup) + } + + // Register cleanup if any + if test.Cleanup != nil { + cleanups = append(cleanups, test.Cleanup) + } + + // Run optional post requirement hook + if registeredTestable != nil { + registeredTestable.AmbientRequirements(test, test.t) + } + + // Set parallel unless asked not to + if !test.NoParallel { + test.t.Parallel() + } + + // Execute cleanups now + test.t.Log("======================== Pre-test cleanup ========================") + for _, cleanup := range cleanups { + cleanup(test.Data, test.helpers) + } + + // Register the cleanups, in reverse + test.t.Cleanup(func() { + test.t.Log("======================== Post-test cleanup ========================") + slices.Reverse(cleanups) + for _, cleanup := range cleanups { + cleanup(test.Data, test.helpers) + } + }) + + // Run the setups + test.t.Log("======================== Test setup ========================") + for _, setup := range setups { + setup(test.Data, test.helpers) + } + + // Run the command if any, with expectations + // Note: if we have a command, we already know we DO have Expected + test.t.Log("======================== Test Run ========================") + if test.Command != nil { + test.Command(test.Data, test.helpers).Run(test.Expected(test.Data, test.helpers)) + } + + // Now go for the subtests + test.t.Log("======================== Processing subtests ========================") + for _, subTest := range test.SubTests { + subTest.parent = test + subTest.Run(test.t) + } + } + + if test.parent != nil { + t.Run(test.Description, testRun) + } else { + testRun(t) + } +} diff --git a/pkg/testutil/test/command.go b/pkg/testutil/test/command.go new file mode 100644 index 00000000000..5f072e647a8 --- /dev/null +++ b/pkg/testutil/test/command.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 test + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" +) + +const ExitCodeGenericFail = -1 +const ExitCodeNoCheck = -2 + +// GenericCommand is a concrete Command implementation +type GenericCommand struct { + Config Config + TempDir string + Env map[string]string + + t *testing.T + + helperBinary string + helperArgs []string + prependArgs []string + mainBinary string + mainArgs []string + + envBlackList []string + stdin io.Reader + async bool + pty bool + timeout time.Duration + workingDir string + + result *icmd.Result + rawStdErr string +} + +func (gc *GenericCommand) WithBinary(binary string) { + gc.mainBinary = binary +} + +func (gc *GenericCommand) WithArgs(args ...string) { + gc.mainArgs = append(gc.mainArgs, args...) +} + +func (gc *GenericCommand) WithWrapper(binary string, args ...string) { + gc.helperBinary = binary + gc.helperArgs = args +} + +func (gc *GenericCommand) WithPseudoTTY() { + gc.pty = true +} + +func (gc *GenericCommand) WithStdin(r io.Reader) { + gc.stdin = r +} + +func (gc *GenericCommand) WithCwd(path string) { + gc.workingDir = path +} + +// TODO: it should be possible to timeout execution +// Primitives (gc.timeout) is here, it is just a matter of exposing a WithTimeout method +// - UX to be decided +// - validate use case: would we ever need this? + +func (gc *GenericCommand) Run(expect *Expected) { + if gc.t != nil { + gc.t.Helper() + } + + var result *icmd.Result + var env []string + if gc.async { + result = icmd.WaitOnCmd(gc.timeout, gc.result) + env = gc.result.Cmd.Env + } else { + iCmdCmd := gc.boot() + env = iCmdCmd.Env + + if gc.pty { + pty, tty, _ := Open() + iCmdCmd.Stdin = tty + iCmdCmd.Stdout = tty + iCmdCmd.Stderr = tty + defer pty.Close() + defer tty.Close() + } + + // Run it + result = icmd.RunCmd(iCmdCmd) + } + + gc.rawStdErr = result.Stderr() + + // Check our expectations, if any + if expect != nil { + // Build the debug string - additionally attach the env (which iCmd does not do) + debug := result.String() + "Env:\n" + strings.Join(env, "\n") + // ExitCode goes first + if expect.ExitCode == ExitCodeNoCheck { //nolint:revive + // -2 means we do not care at all about exit code + } else if expect.ExitCode == ExitCodeGenericFail { + // -1 means any error + assert.Assert(gc.t, result.ExitCode != 0, + "Expected exit code to be different than 0\n"+debug) + } else { + assert.Assert(gc.t, expect.ExitCode == result.ExitCode, + fmt.Sprintf("Expected exit code: %d\n", expect.ExitCode)+debug) + } + // Range through the expected errors and confirm they are seen on stderr + for _, expectErr := range expect.Errors { + assert.Assert(gc.t, strings.Contains(gc.rawStdErr, expectErr.Error()), + fmt.Sprintf("Expected error: %q to be found in stderr\n", expectErr.Error())+debug) + } + // Finally, check the output if we are asked to + if expect.Output != nil { + expect.Output(result.Stdout(), debug, gc.t) + } + } +} + +func (gc *GenericCommand) Stderr() string { + return gc.rawStdErr +} + +func (gc *GenericCommand) Background(timeout time.Duration) { + // Run it + gc.async = true + i := gc.boot() + gc.timeout = timeout + gc.result = icmd.StartCmd(i) +} + +func (gc *GenericCommand) withEnv(env map[string]string) { + if gc.Env == nil { + gc.Env = map[string]string{} + } + for k, v := range env { + gc.Env[k] = v + } +} + +func (gc *GenericCommand) withTempDir(path string) { + gc.TempDir = path +} + +func (gc *GenericCommand) WithBlacklist(env []string) { + gc.envBlackList = env +} + +func (gc *GenericCommand) withConfig(config Config) { + gc.Config = config +} + +func (gc *GenericCommand) PrependArgs(args ...string) { + gc.prependArgs = append(gc.prependArgs, args...) +} + +func (gc *GenericCommand) Clone() TestableCommand { + // Copy the command and return a new one - with almost everything from the parent command + cc := *gc + cc.result = nil + cc.stdin = nil + cc.timeout = 0 + cc.rawStdErr = "" + // Clone Env + cc.Env = make(map[string]string, len(gc.Env)) + for k, v := range gc.Env { + cc.Env[k] = v + } + return &cc +} + +func (gc *GenericCommand) T() *testing.T { + return gc.t +} + +func (gc *GenericCommand) clear() TestableCommand { + cc := *gc + cc.mainBinary = "" + cc.helperBinary = "" + cc.mainArgs = []string{} + cc.prependArgs = []string{} + cc.helperArgs = []string{} + // Clone Env + cc.Env = make(map[string]string, len(gc.Env)) + // Reset configuration + cc.Config = &config{} + for k, v := range gc.Env { + cc.Env[k] = v + } + return &cc +} + +func (gc *GenericCommand) withT(t *testing.T) { + gc.t = t +} + +func (gc *GenericCommand) read(key ConfigKey) ConfigValue { + return gc.Config.Read(key) +} + +func (gc *GenericCommand) write(key ConfigKey, value ConfigValue) { + gc.Config.Write(key, value) +} + +func (gc *GenericCommand) boot() icmd.Cmd { + // This is a helper function, not to appear in the debugging output + if gc.t != nil { + gc.t.Helper() + } + + binary := gc.mainBinary + args := append(gc.prependArgs, gc.mainArgs...) + if gc.helperBinary != "" { + args = append([]string{binary}, args...) + args = append(gc.helperArgs, args...) + binary = gc.helperBinary + } + + // Create the command and set the env + // TODO: do we really need iCmd? + gc.t.Log(binary, strings.Join(args, " ")) + + iCmdCmd := icmd.Command(binary, args...) + iCmdCmd.Env = []string{} + for _, v := range os.Environ() { + add := true + for _, b := range gc.envBlackList { + if strings.HasPrefix(v, b+"=") { + add = false + break + } + } + if add { + iCmdCmd.Env = append(iCmdCmd.Env, v) + } + } + + // Ensure the subprocess gets executed in a temporary directory unless explicitly instructed otherwise + iCmdCmd.Dir = gc.workingDir + + if gc.stdin != nil { + iCmdCmd.Stdin = gc.stdin + } + + // Attach any extra env we have + for k, v := range gc.Env { + iCmdCmd.Env = append(iCmdCmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + + return iCmdCmd +} diff --git a/pkg/testutil/test/config.go b/pkg/testutil/test/config.go new file mode 100644 index 00000000000..6cf5ceb0c25 --- /dev/null +++ b/pkg/testutil/test/config.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 test + +// WithConfig returns a config object with a certain config property set +func WithConfig(key ConfigKey, value ConfigValue) Config { + cfg := &config{} + cfg.Write(key, value) + return cfg +} + +// Contains the implementation of the Config interface + +func configureConfig(cfg Config, parent Config) Config { + if cfg == nil { + cfg = &config{ + config: make(map[ConfigKey]ConfigValue), + } + } + if parent != nil { + // Note: implementation dependent + cfg.(*config).adopt(parent) + } + return cfg +} + +type config struct { + config map[ConfigKey]ConfigValue +} + +func (cfg *config) Write(key ConfigKey, value ConfigValue) Config { + if cfg.config == nil { + cfg.config = make(map[ConfigKey]ConfigValue) + } + cfg.config[key] = value + return cfg +} + +func (cfg *config) Read(key ConfigKey) ConfigValue { + if cfg.config == nil { + cfg.config = make(map[ConfigKey]ConfigValue) + } + if val, ok := cfg.config[key]; ok { + return val + } + return "" +} + +func (cfg *config) adopt(parent Config) { + // Note: implementation dependent + for k, v := range parent.(*config).config { + // Only copy keys that are not set already + if _, ok := cfg.config[k]; !ok { + cfg.Write(k, v) + } + } +} diff --git a/pkg/testutil/test/data.go b/pkg/testutil/test/data.go new file mode 100644 index 00000000000..97c7659c6d5 --- /dev/null +++ b/pkg/testutil/test/data.go @@ -0,0 +1,111 @@ +/* + 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 test + +import ( + "crypto/sha256" + "fmt" + "regexp" + "strings" + "testing" +) + +// WithData returns a data object with a certain key value set +func WithData(key string, value string) Data { + dat := &data{} + dat.Set(key, value) + return dat +} + +// Contains the implementation of the Data interface + +func configureData(t *testing.T, seedData Data, parent Data) Data { + if seedData == nil { + seedData = &data{} + } + dat := &data{ + // Note: implementation dependent + labels: seedData.(*data).labels, + tempDir: t.TempDir(), + testID: func(suffix ...string) string { + suffix = append([]string{t.Name()}, suffix...) + return defaultIdentifierHashing(suffix...) + }, + } + if parent != nil { + dat.adopt(parent) + } + return dat +} + +type data struct { + labels map[string]string + testID func(suffix ...string) string + tempDir string +} + +func (dt *data) Get(key string) string { + return dt.labels[key] +} + +func (dt *data) Set(key string, value string) Data { + if dt.labels == nil { + dt.labels = map[string]string{} + } + dt.labels[key] = value + return dt +} + +func (dt *data) Identifier(suffix ...string) string { + return dt.testID(suffix...) +} + +func (dt *data) TempDir() string { + return dt.tempDir +} + +func (dt *data) adopt(parent Data) { + // Note: implementation dependent + for k, v := range parent.(*data).labels { + // Only copy keys that are not set already + if _, ok := dt.labels[k]; !ok { + dt.Set(k, v) + } + } +} + +func defaultIdentifierHashing(names ...string) string { + // Notes: identifier MAY be used for namespaces, image names, etc. + // So, the rules are stringent on what it can contain. + replaceWith := []byte("-") + name := strings.ToLower(strings.Join(names, string(replaceWith))) + // Ensure we have a unique identifier despite characters replacements (well, as unique as the names collection being passed) + signature := fmt.Sprintf("%x", sha256.Sum256([]byte(name)))[0:8] + // Make sure we do not use any unsafe characters + safeName := regexp.MustCompile(`[^a-z0-9-]+`) + // And we avoid repeats of the separator + noRepeat := regexp.MustCompile(fmt.Sprintf(`[%s]{2,}`, replaceWith)) + sn := safeName.ReplaceAll([]byte(name), replaceWith) + sn = noRepeat.ReplaceAll(sn, replaceWith) + // Do not allow trailing or leading dash (as that may stutter) + name = strings.Trim(string(sn), string(replaceWith)) + // Ensure we will never go above 76 characters in length (with signature) + if len(name) > 67 { + name = name[0:67] + } + return name + "-" + signature +} diff --git a/pkg/testutil/test/expected.go b/pkg/testutil/test/expected.go new file mode 100644 index 00000000000..9057974c909 --- /dev/null +++ b/pkg/testutil/test/expected.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 test + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +// RunCommand is the simplest way to express a test.TestableCommand for very basic cases when access to test data is not necessary +func Command(args ...string) Executor { + return func(data Data, helpers Helpers) TestableCommand { + return helpers.Command(args...) + } +} + +// Expects is provided as a simple helper covering "expectations" for simple use-cases where access to the test data is not necessary +func Expects(exitCode int, errors []error, output Comparator) Manager { + return func(_ Data, _ Helpers) *Expected { + return &Expected{ + ExitCode: exitCode, + Errors: errors, + Output: output, + } + } +} + +// All can be used as a parameter for expected.Output to group a set of comparators +func All(comparators ...Comparator) Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + for _, comparator := range comparators { + comparator(stdout, info, t) + } + } +} + +// 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) Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + assert.Check(t, strings.Contains(stdout, compare), fmt.Sprintf("Output does not contain: %q", compare)+info) + } +} + +// DoesNotContain is to be used for expected.Output to ensure a comparison string is NOT found in the output +func DoesNotContain(compare string) Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + assert.Check(t, !strings.Contains(stdout, compare), fmt.Sprintf("Output does contain: %q", compare)+info) + } +} + +// Equals is to be used for expected.Output to ensure it is exactly the output +func Equals(compare string) Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + assert.Equal(t, compare, stdout, info) + } +} + +// Provisional - expected use, but have not seen it so far +// Match is to be used for expected.Output to ensure we match a regexp +func Match(reg *regexp.Regexp) Comparator { + return func(stdout string, info string, t *testing.T) { + t.Helper() + assert.Check(t, reg.MatchString(stdout), fmt.Sprintf("Output does not match: %s", reg), info) + } +} diff --git a/pkg/testutil/test/helpers.go b/pkg/testutil/test/helpers.go new file mode 100644 index 00000000000..9247f4098e8 --- /dev/null +++ b/pkg/testutil/test/helpers.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 test + +import "testing" + +// Helpers provides a set of helpers to run commands with simple expectations, available at all stages of a test (Setup, Cleanup, etc...) +type Helpers interface { + // Ensure runs a command and verifies it is succeeding + Ensure(args ...string) + // Anyhow runs a command and ignores its result + Anyhow(args ...string) + // Fail runs a command and verifies it failed + Fail(args ...string) + // Capture runs a command, verifies it succeeded, and returns stdout + Capture(args ...string) string + // Err runs a command, and returns stderr regardless of its outcome + // This is mostly useful for debugging + Err(args ...string) string + + // Command will return a populated command from the default internal command, with the provided arguments, + // ready to be Run or further configured + Command(args ...string) TestableCommand + // Custom will return a bare command, without configuration nor defaults (still has the Env) + Custom(binary string, args ...string) TestableCommand + + // Read return the config value associated with a key + Read(key ConfigKey) ConfigValue + // Write saves a value in the config + Write(key ConfigKey, value ConfigValue) + + // T returns the current testing object + T() *testing.T +} + +// This is the implementation of Helpers + +type helpersInternal struct { + cmdInternal CustomizableCommand + + t *testing.T +} + +// Ensure will run a command and make sure it is successful +func (help *helpersInternal) Ensure(args ...string) { + help.Command(args...).Run(&Expected{ + ExitCode: 0, + }) +} + +// Anyhow will run a command regardless of outcome (may or may not fail) +func (help *helpersInternal) Anyhow(args ...string) { + help.Command(args...).Run(nil) +} + +// Fail will run a command and make sure it does fail +func (help *helpersInternal) Fail(args ...string) { + help.Command(args...).Run(&Expected{ + ExitCode: ExitCodeGenericFail, + }) +} + +// Capture will run a command, ensure it is successful and return stdout +func (help *helpersInternal) Capture(args ...string) string { + var ret string + help.Command(args...).Run(&Expected{ + Output: func(stdout string, info string, t *testing.T) { + ret = stdout + }, + }) + return ret +} + +// Capture will run a command, ensure it is successful and return stdout +func (help *helpersInternal) Err(args ...string) string { + cmd := help.Command(args...) + cmd.Run(nil) + return cmd.Stderr() +} + +// Command will return a clone of your base command without running it +func (help *helpersInternal) Command(args ...string) TestableCommand { + cc := help.cmdInternal.Clone() + cc.WithArgs(args...) + return cc +} + +// Custom will return a command for the requested binary and args, with the environment of your test +// (eg: Env, Cwd, etc.) +func (help *helpersInternal) Custom(binary string, args ...string) TestableCommand { + cc := help.cmdInternal.clear() + cc.WithBinary(binary) + cc.WithArgs(args...) + return cc +} + +func (help *helpersInternal) Read(key ConfigKey) ConfigValue { + return help.cmdInternal.read(key) +} + +func (help *helpersInternal) Write(key ConfigKey, value ConfigValue) { + help.cmdInternal.write(key, value) +} + +func (help *helpersInternal) T() *testing.T { + return help.t +} diff --git a/pkg/testutil/test/pty.go b/pkg/testutil/test/pty.go new file mode 100644 index 00000000000..97a73910c16 --- /dev/null +++ b/pkg/testutil/test/pty.go @@ -0,0 +1,22 @@ +/* + 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 test + +import "errors" + +var ErrPTYFailure = errors.New("pty failure") +var ErrPTYUnsupportedPlatform = errors.New("pty not supported on this platform") diff --git a/pkg/testutil/test/pty_freebsd.go b/pkg/testutil/test/pty_freebsd.go new file mode 100644 index 00000000000..dda64ae6c8c --- /dev/null +++ b/pkg/testutil/test/pty_freebsd.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 test + +import ( + "os" +) + +func Open() (pty, tty *os.File, err error) { + return nil, nil, ErrPTYUnsupportedPlatform +} diff --git a/pkg/testutil/test/pty_linux.go b/pkg/testutil/test/pty_linux.go new file mode 100644 index 00000000000..37226938ee1 --- /dev/null +++ b/pkg/testutil/test/pty_linux.go @@ -0,0 +1,72 @@ +/* + 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 test + +import ( + "errors" + "os" + "strconv" + "syscall" + "unsafe" +) + +// Inspiration from https://github.com/creack/pty/tree/2cde18bfb702199728dd43bf10a6c15c7336da0a + +func Open() (pty, tty *os.File, err error) { + defer func() { + if err != nil && pty != nil { + err = errors.Join(pty.Close(), err) + } + if err != nil { + err = errors.Join(ErrPTYFailure, err) + } + }() + + pty, err = os.OpenFile("/dev/ptmx", os.O_RDWR, 0) + if err != nil { + return nil, nil, err + } + + var n uint32 + err = ioctl(pty, syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))) + if err != nil { + return nil, nil, err + } + + sname := "/dev/pts/" + strconv.Itoa(int(n)) + + var u int32 + err = ioctl(pty, syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&u))) + if err != nil { + return nil, nil, err + } + + tty, err = os.OpenFile(sname, os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + return nil, nil, err + } + + return pty, tty, nil +} + +func ioctl(f *os.File, cmd, ptr uintptr) error { + _, _, e := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), cmd, ptr) + if e != 0 { + return e + } + return nil +} diff --git a/pkg/testutil/test/pty_windows.go b/pkg/testutil/test/pty_windows.go new file mode 100644 index 00000000000..dda64ae6c8c --- /dev/null +++ b/pkg/testutil/test/pty_windows.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 test + +import ( + "os" +) + +func Open() (pty, tty *os.File, err error) { + return nil, nil, ErrPTYUnsupportedPlatform +} diff --git a/pkg/testutil/test/requirement.go b/pkg/testutil/test/requirement.go new file mode 100644 index 00000000000..9a7abe0ca16 --- /dev/null +++ b/pkg/testutil/test/requirement.go @@ -0,0 +1,115 @@ +/* + 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 test + +import ( + "fmt" + "os/exec" + "runtime" +) + +func Binary(name string) *Requirement { + return &Requirement{ + Check: func(data Data, helpers Helpers) (bool, string) { + mess := fmt.Sprintf("executable %q has been found in PATH", name) + ret := true + if _, err := exec.LookPath(name); err != nil { + ret = false + mess = fmt.Sprintf("executable %q doesn't exist in PATH", name) + } + + return ret, mess + }, + } +} + +func OS(os string) *Requirement { + return &Requirement{ + Check: func(data Data, helpers Helpers) (bool, string) { + mess := fmt.Sprintf("current operating system is %q", runtime.GOOS) + ret := true + if runtime.GOOS != os { + ret = false + } + + return ret, mess + }, + } +} + +func Arch(arch string) *Requirement { + return &Requirement{ + Check: func(data Data, helpers Helpers) (bool, string) { + mess := fmt.Sprintf("current architecture is %q", runtime.GOARCH) + ret := true + if runtime.GOARCH != arch { + ret = false + } + + return ret, mess + }, + } +} + +var Amd64 = Arch("amd64") +var Arm64 = Arch("arm64") +var Windows = OS("windows") +var Linux = OS("linux") +var Darwin = OS("darwin") + +// NOTE: Not will always lose setups and cleanups... + +func Not(requirement *Requirement) *Requirement { + return &Requirement{ + Check: func(data Data, helpers Helpers) (bool, string) { + ret, mess := requirement.Check(data, helpers) + return !ret, mess + }, + } +} + +func Require(requirements ...*Requirement) *Requirement { + return &Requirement{ + Check: func(data Data, helpers Helpers) (bool, string) { + ret := true + mess := "" + var subMess string + for _, requirement := range requirements { + ret, subMess = requirement.Check(data, helpers) + mess += "\n" + subMess + if !ret { + return ret, mess + } + } + return ret, mess + }, + Setup: func(data Data, helpers Helpers) { + for _, requirement := range requirements { + if requirement.Setup != nil { + requirement.Setup(data, helpers) + } + } + }, + Cleanup: func(data Data, helpers Helpers) { + for _, requirement := range requirements { + if requirement.Cleanup != nil { + requirement.Cleanup(data, helpers) + } + } + }, + } +} diff --git a/pkg/testutil/test/test.go b/pkg/testutil/test/test.go new file mode 100644 index 00000000000..07a7e3b1c60 --- /dev/null +++ b/pkg/testutil/test/test.go @@ -0,0 +1,162 @@ +/* + 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 test + +import ( + "io" + "testing" + "time" +) + +// A Requirement offers a way to verify random conditions to decide if a test should be skipped or run. +// It can furthermore (optionally) provide custom Setup and Cleanup routines. +type Requirement struct { + // Check is expected to perform random operations and return a boolean and an explanatory message + Check Evaluator + // Setup, if provided, will be run before any test-specific Setup routine, in the order that requirements have been declared + Setup Butler + // Cleanup, if provided, will be run after any test-specific Cleanup routine, in the revers order that requirements have been declared + Cleanup Butler +} + +// An Evaluator is a function that decides whether a test should run or not +type Evaluator func(data Data, helpers Helpers) (bool, string) + +// A Butler is the function signature meant to be attached to a Setup or Cleanup routine for a Case or Requirement +type Butler func(data Data, helpers Helpers) + +// An Executor is the function signature meant to be attached to the Command property of a Case +type Executor func(data Data, helpers Helpers) TestableCommand + +// A Manager is the function signature to be run to produce expectations to be fed to a command +type Manager func(data Data, helpers Helpers) *Expected + +// A Comparator is the function signature to implement for the Output property of an Expected +type Comparator func(stdout string, info string, t *testing.T) + +// Expected expresses the expected output of a command +type Expected struct { + // ExitCode + ExitCode int + // Errors contains any error that (once serialized) should be seen in stderr + Errors []error + // Output function to match against stdout + Output Comparator +} + +// Data is meant to hold information about a test: +// - first, any random key value data that the test implementer wants to carry / modify - this is test data +// - second, some commonly useful immutable test properties (a way to generate unique identifiers for that test, +// temporary directory, etc.) +// Note that Data is inherited, from parent test to subtest (except for Identifier and TempDir of course) +type Data interface { + // Get returns the value of a certain key for custom data + Get(key string) string + // Set will save `value` for `key` + Set(key string, value string) Data + + // Identifier returns the test identifier that can be used to name resources + Identifier(suffix ...string) string + // TempDir returns the test temporary directory + TempDir() string +} + +type ConfigKey string +type ConfigValue string + +// Config is meant to hold information relevant to the binary (eg: flags defining certain behaviors, etc.) +type Config interface { + // Write + Write(key ConfigKey, value ConfigValue) Config + // Read + Read(key ConfigKey) ConfigValue +} + +// The TestableCommand interface represents a low-level command to execute, typically to be compared with an Expected +// A TestableCommand can be used as a Case Command obviously, but also as part of a Setup or Cleanup routine, +// and as the basis of any type of helper. +// For more powerful usecase outside of test cases, see below CustomizableCommand +type TestableCommand interface { + // WithBinary specifies what binary to execute + WithBinary(binary string) + // WithArgs specifies the args to pass to the binary. Note that WithArgs can be used multiple times and is additive. + WithArgs(args ...string) + // WithWrapper allows wrapping a command with another command (for example: `time`, `unbuffer`) + WithWrapper(binary string, args ...string) + // WithPseudoTTY + WithPseudoTTY() + // WithStdin allows passing a reader to be used for stdin for the command + WithStdin(r io.Reader) + // WithCwd allows specifying the working directory for the command + WithCwd(path string) + // Clone returns a copy of the command + Clone() TestableCommand + + // Run does execute the command, and compare the output with the provided expectation. + // Passing nil for `Expected` will just run the command regardless of outcome. + // An empty `&Expected{}` is (of course) equivalent to &Expected{Exit: 0}, meaning the command is verified to be + // successful + Run(expect *Expected) + // Background allows starting a command in the background + Background(timeout time.Duration) + // Stderr allows retrieving the raw stderr output of the command + Stderr() string +} + +// ///////////////////////////////////////////// +// CustomizableCommand is an interface meant for people who want to heavily customize the base command of their test case +// It is passed along +type CustomizableCommand interface { + TestableCommand + + PrependArgs(args ...string) + // WithBlacklist allows to filter out unwanted variables from the embedding environment - default it pass any that is + // defined by WithEnv + WithBlacklist(env []string) + + // 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 + withEnv(env map[string]string) + // withTempDir specifies a temporary directory to use + withTempDir(path string) + // WithConfig allows passing custom config properties from the test to the base command + withConfig(config Config) + withT(t *testing.T) + // Clear does a clone, but will clear binary and arguments, but retain 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 GenericCommand + clear() TestableCommand + + // Will manipulate specific configuration option on the command + // Note that config is a copy of the test config + // Any modification done here will not be passed along to subtests, although they are shared amongst all commands of the test. + write(key ConfigKey, value ConfigValue) + read(key ConfigKey) ConfigValue +} + +type Testable interface { + CustomCommand(testCase *Case, t *testing.T) CustomizableCommand + AmbientRequirements(testCase *Case, t *testing.T) +} + +var ( + registeredTestable Testable +) + +func Customize(testable Testable) { + registeredTestable = testable +} diff --git a/pkg/testutil/test/utilities.go b/pkg/testutil/test/utilities.go new file mode 100644 index 00000000000..26da36bdbaa --- /dev/null +++ b/pkg/testutil/test/utilities.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 test + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// RandomStringBase64 generates a base64 encoded random string +func RandomStringBase64(n int) string { + b := make([]byte, n) + l, err := rand.Read(b) + if err != nil { + panic(err) + } + if l != n { + panic(fmt.Errorf("expected %d bytes, got %d bytes", n, l)) + } + return base64.URLEncoding.EncodeToString(b) +} diff --git a/pkg/testutil/testca/testca.go b/pkg/testutil/testca/testca.go index 3ae92ccc923..53a0eddd37c 100644 --- a/pkg/testutil/testca/testca.go +++ b/pkg/testutil/testca/testca.go @@ -95,12 +95,14 @@ func (c *Cert) Close() error { return c.closeF() } -func (ca *CA) NewCert(host string) *Cert { +func (ca *CA) NewCert(host string, additional ...string) *Cert { t := ca.t key, err := rsa.GenerateKey(rand.Reader, keyLength) assert.NilError(t, err) + additional = append([]string{host}, additional...) + cert := &x509.Certificate{ SerialNumber: serialNumber(t), Subject: pkix.Name{ @@ -111,10 +113,12 @@ func (ca *CA) NewCert(host string) *Cert { NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageCRLSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{host}, + DNSNames: additional, } - if ip := net.ParseIP(host); ip != nil { - cert.IPAddresses = append(cert.IPAddresses, ip) + for _, h := range additional { + if ip := net.ParseIP(h); ip != nil { + cert.IPAddresses = append(cert.IPAddresses, ip) + } } dir, err := os.MkdirTemp(t.TempDir(), "cert") diff --git a/pkg/testutil/testregistry/certsd_linux.go b/pkg/testutil/testregistry/certsd_linux.go new file mode 100644 index 00000000000..2a9587e08c4 --- /dev/null +++ b/pkg/testutil/testregistry/certsd_linux.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 testregistry + +import ( + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/hoststoml" +) + +func generateCertsd(dir string, certPath string, hostIP string, port int) error { + return (&hoststoml.HostsToml{ + CA: certPath, + }).Save(dir, hostIP, port) +} diff --git a/pkg/testutil/testregistry/testregistry_linux.go b/pkg/testutil/testregistry/testregistry_linux.go index 9eb77985e9e..d6610f9046a 100644 --- a/pkg/testutil/testregistry/testregistry_linux.go +++ b/pkg/testutil/testregistry/testregistry_linux.go @@ -23,177 +23,85 @@ import ( "path/filepath" "strconv" - "github.com/containerd/nerdctl/pkg/testutil" - "github.com/containerd/nerdctl/pkg/testutil/nettestutil" - "github.com/containerd/nerdctl/pkg/testutil/testca" - "golang.org/x/crypto/bcrypt" "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/platform" + "github.com/containerd/nerdctl/v2/pkg/testutil/nettestutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/portlock" + "github.com/containerd/nerdctl/v2/pkg/testutil/testca" ) -type TestRegistry struct { - IP net.IP - ListenIP net.IP - ListenPort int - HostsDir string // contains ":/hosts.toml" - Cleanup func() - Logs func() +type RegistryServer struct { + IP net.IP + Port int + Scheme string + ListenIP net.IP + Cleanup func(err error) + Logs func() + HostsDir string // contains ":/hosts.toml" } -func NewPlainHTTP(base *testutil.Base, port int) *TestRegistry { - hostIP, err := nettestutil.NonLoopbackIPv4() - assert.NilError(base.T, err) - // listen on 0.0.0.0 to enable 127.0.0.1 - listenIP := net.ParseIP("0.0.0.0") - listenPort := port - base.T.Logf("hostIP=%q, listenIP=%q, listenPort=%d", hostIP, listenIP, listenPort) +type TokenAuthServer struct { + IP net.IP + Port int + Scheme string + ListenIP net.IP + Cleanup func(err error) + Logs func() + Auth Auth + CertPath string +} - registryContainerName := "reg-" + testutil.Identifier(base.T) - cmd := base.Cmd("run", - "-d", - "-p", fmt.Sprintf("%s:%d:5000", listenIP, listenPort), - "--name", registryContainerName, - testutil.RegistryImage) - cmd.AssertOK() - if _, err = nettestutil.HTTPGet(fmt.Sprintf("http://%s:%d/v2", hostIP.String(), listenPort), 30, false); err != nil { - base.Cmd("rm", "-f", registryContainerName).Run() - base.T.Fatal(err) - } - return &TestRegistry{ - IP: hostIP, - ListenIP: listenIP, - ListenPort: listenPort, - Cleanup: func() { base.Cmd("rm", "-f", registryContainerName).AssertOK() }, +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() } -func NewAuthWithHTTP(base *testutil.Base, user, pass string, listenPort int, authPort int) *TestRegistry { +func NewAuthServer(base *testutil.Base, ca *testca.CA, port int, user, pass string, tls bool) *TokenAuthServer { + EnsureImages(base) + name := testutil.Identifier(base.T) - hostIP, err := nettestutil.NonLoopbackIPv4() - assert.NilError(base.T, err) // listen on 0.0.0.0 to enable 127.0.0.1 listenIP := net.ParseIP("0.0.0.0") - base.T.Logf("hostIP=%q, listenIP=%q, listenPort=%d, authPort=%d", hostIP, listenIP, listenPort, authPort) - - ca := testca.New(base.T) - registryCert := ca.NewCert(hostIP.String()) - authCert := ca.NewCert(hostIP.String()) - + hostIP, err := nettestutil.NonLoopbackIPv4() + assert.NilError(base.T, err, fmt.Errorf("failed finding ipv4 non loopback interface: %w", err)) // Prepare configuration file for authentication server // Details: https://github.com/cesanta/docker_auth/blob/1.7.1/examples/simple.yml - authConfigFile, err := os.CreateTemp("", "authconfig") - assert.NilError(base.T, err) + configFile, err := os.CreateTemp("", "authconfig") + assert.NilError(base.T, err, fmt.Errorf("failed creating temporary directory for config file: %w", err)) bpass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) - assert.NilError(base.T, err) - authConfigFileName := authConfigFile.Name() - _, err = authConfigFile.Write([]byte(fmt.Sprintf(` + assert.NilError(base.T, err, fmt.Errorf("failed bcrypt encrypting password: %w", err)) + configFileName := configFile.Name() + scheme := "http" + configContent := fmt.Sprintf(` server: addr: ":5100" - certificate: "/auth/domain.crt" - key: "/auth/domain.key" token: issuer: "Acme auth server" expiration: 900 + certificate: "/auth/domain.crt" + key: "/auth/domain.key" users: "%s": password: "%s" acl: - match: {account: "%s"} actions: ["*"] -`, user, string(bpass), user))) - assert.NilError(base.T, err) - - // Run authentication server - authContainerName := fmt.Sprintf("auth-%s-%d", name, authPort) - cmd := base.Cmd("run", - "-d", - "-p", fmt.Sprintf("%s:%d:5100", listenIP, authPort), - "--name", authContainerName, - "-v", authCert.CertPath+":/auth/domain.crt", - "-v", authCert.KeyPath+":/auth/domain.key", - "-v", authConfigFileName+":/config/auth_config.yml", - testutil.DockerAuthImage, - "/config/auth_config.yml") - cmd.AssertOK() - - // Run docker_auth-enabled registry - // Details: https://github.com/cesanta/docker_auth/blob/1.7.1/examples/simple.yml - registryContainerName := fmt.Sprintf("%s-%s-%d", "reg", name, listenPort) - cmd = base.Cmd("run", - "-d", - "-p", fmt.Sprintf("%s:%d:5000", listenIP, listenPort), - "--name", registryContainerName, - "--env", "REGISTRY_AUTH=token", - "--env", "REGISTRY_AUTH_TOKEN_REALM="+fmt.Sprintf("https://%s:%d/auth", hostIP.String(), authPort), - "--env", "REGISTRY_AUTH_TOKEN_SERVICE=Docker registry", - "--env", "REGISTRY_AUTH_TOKEN_ISSUER=Acme auth server", - "--env", "REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/auth/domain.crt", - // rootcertbundle is not CA cert: https://github.com/distribution/distribution/issues/1143 - "-v", authCert.CertPath+":/auth/domain.crt", - testutil.RegistryImage) - cmd.AssertOK() - joined := net.JoinHostPort(hostIP.String(), strconv.Itoa(listenPort)) - if _, err = nettestutil.HTTPGet(fmt.Sprintf("http://%s/v2", joined), 30, true); err != nil { - base.Cmd("rm", "-f", registryContainerName).Run() - base.T.Fatal(err) - } - hostsDir, err := os.MkdirTemp(base.T.TempDir(), "certs.d") - assert.NilError(base.T, err) - hostsSubDir := filepath.Join(hostsDir, joined) - err = os.MkdirAll(hostsSubDir, 0700) - assert.NilError(base.T, err) - hostsTOMLPath := filepath.Join(hostsSubDir, "hosts.toml") - // See https://github.com/containerd/containerd/blob/main/docs/hosts.md - hostsTOML := fmt.Sprintf(` -server = "https://%s" -[host."https://%s"] - ca = %q - `, joined, joined, ca.CertPath) - base.T.Logf("Writing %q: %q", hostsTOMLPath, hostsTOML) - err = os.WriteFile(hostsTOMLPath, []byte(hostsTOML), 0700) - assert.NilError(base.T, err) - return &TestRegistry{ - IP: hostIP, - ListenIP: listenIP, - ListenPort: listenPort, - HostsDir: hostsDir, - Cleanup: func() { - base.Cmd("rm", "-f", registryContainerName).AssertOK() - base.Cmd("rm", "-f", authContainerName).AssertOK() - assert.NilError(base.T, registryCert.Close()) - assert.NilError(base.T, authCert.Close()) - assert.NilError(base.T, authConfigFile.Close()) - os.Remove(authConfigFileName) - }, - Logs: func() { - base.T.Logf("%s: %q", registryContainerName, base.Cmd("logs", registryContainerName).Run().String()) - base.T.Logf("%s: %q", authContainerName, base.Cmd("logs", authContainerName).Run().String()) - }, - } -} - -func NewHTTPS(base *testutil.Base, user, pass string) *TestRegistry { - name := testutil.Identifier(base.T) - hostIP, err := nettestutil.NonLoopbackIPv4() - assert.NilError(base.T, err) - // listen on 0.0.0.0 to enable 127.0.0.1 - listenIP := net.ParseIP("0.0.0.0") - const listenPort = 5000 // TODO: choose random empty port - const authPort = 5100 // TODO: choose random empty port - base.T.Logf("hostIP=%q, listenIP=%q, listenPort=%d, authPort=%d", hostIP, listenIP, listenPort, authPort) - - ca := testca.New(base.T) - registryCert := ca.NewCert(hostIP.String()) - authCert := ca.NewCert(hostIP.String()) - - // Prepare configuration file for authentication server - // Details: https://github.com/cesanta/docker_auth/blob/1.7.1/examples/simple.yml - authConfigFile, err := os.CreateTemp("", "authconfig") - assert.NilError(base.T, err) - bpass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) - assert.NilError(base.T, err) - authConfigFileName := authConfigFile.Name() - _, err = authConfigFile.Write([]byte(fmt.Sprintf(` +`, user, string(bpass), user) + if tls { + scheme = "https" + configContent = fmt.Sprintf(` server: addr: ":5100" certificate: "/auth/domain.crt" @@ -207,78 +115,286 @@ users: acl: - match: {account: "%s"} actions: ["*"] -`, user, string(bpass), user))) - assert.NilError(base.T, err) +`, user, string(bpass), user) + } + _, err = configFile.Write([]byte(configContent)) + assert.NilError(base.T, err, fmt.Errorf("failed writing configuration: %w", err)) - // Run authentication server - authContainerName := "auth-" + name - cmd := base.Cmd("run", - "-d", - "-p", fmt.Sprintf("%s:%d:5100", listenIP, authPort), - "--name", authContainerName, - "-v", authCert.CertPath+":/auth/domain.crt", - "-v", authCert.KeyPath+":/auth/domain.key", - "-v", authConfigFileName+":/config/auth_config.yml", - testutil.DockerAuthImage, - "/config/auth_config.yml") - cmd.AssertOK() - - // Run docker_auth-enabled registry - // Details: https://github.com/cesanta/docker_auth/blob/1.7.1/examples/simple.yml - registryContainerName := "reg-" + name - cmd = base.Cmd("run", - "-d", - "-p", fmt.Sprintf("%s:%d:5000", listenIP, listenPort), - "--name", registryContainerName, + cert := ca.NewCert(hostIP.String()) + + port, err = portlock.Acquire(port) + assert.NilError(base.T, err, fmt.Errorf("failed acquiring port: %w", err)) + containerName := fmt.Sprintf("auth-%s-%d", name, port) + // Cleanup possible leftovers first + base.Cmd("rm", "-f", containerName).Run() + + cleanup := func(err error) { + result := base.Cmd("rm", "-f", containerName).Run() + errPortRelease := portlock.Release(port) + errCertClose := cert.Close() + errConfigClose := configFile.Close() + errConfigRemove := os.Remove(configFileName) + if err == nil { + assert.NilError(base.T, result.Error, fmt.Errorf("failed stopping container: %w", err)) + assert.NilError(base.T, errPortRelease, fmt.Errorf("failed releasing port: %w", err)) + assert.NilError(base.T, errCertClose, fmt.Errorf("failed cleaning certs: %w", err)) + assert.NilError(base.T, errConfigClose, fmt.Errorf("failed closing config file: %w", err)) + assert.NilError(base.T, errConfigRemove, fmt.Errorf("failed removing config file: %w", err)) + } + } + + err = func() error { + // Run authentication server + cmd := base.Cmd( + "run", + "--pull=never", + "-d", + "-p", fmt.Sprintf("%s:%d:5100", listenIP, port), + "--name", containerName, + "-v", cert.CertPath+":/auth/domain.crt", + "-v", cert.KeyPath+":/auth/domain.key", + "-v", configFileName+":/config/auth_config.yml", + testutil.DockerAuthImage, + "/config/auth_config.yml").Run() + if cmd.Error != nil { + base.T.Logf("%s:\n%s\n%s\n-------\n%s", containerName, cmd.Cmd, cmd.Stdout(), cmd.Stderr()) + return cmd.Error + } + joined := net.JoinHostPort(hostIP.String(), strconv.Itoa(port)) + _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s/auth", scheme, joined), 30, true) + return err + }() + + if err != nil { + cl := base.Cmd("logs", containerName).Run() + base.T.Logf("%s:\n%s\n%s\n=========================\n%s", containerName, cl.Cmd, cl.Stdout(), cl.Stderr()) + cleanup(err) + } + assert.NilError(base.T, err, fmt.Errorf("failed starting auth container in a timely manner: %w", err)) + + return &TokenAuthServer{ + IP: hostIP, + Port: port, + Scheme: scheme, + ListenIP: listenIP, + CertPath: cert.CertPath, + Auth: &TokenAuth{ + Address: scheme + "://" + net.JoinHostPort(hostIP.String(), strconv.Itoa(port)), + CertPath: cert.CertPath, + }, + Cleanup: cleanup, + Logs: func() { + base.T.Logf("%s: %q", containerName, base.Cmd("logs", containerName).Run().String()) + }, + } + +} + +// Auth is an interface to pass to the test registry for configuring authentication +type Auth interface { + Params(*testutil.Base) []string +} + +type NoAuth struct { +} + +func (na *NoAuth) Params(base *testutil.Base) []string { + return []string{} +} + +type TokenAuth struct { + Address string + CertPath string +} + +func (ta *TokenAuth) Params(base *testutil.Base) []string { + return []string{ "--env", "REGISTRY_AUTH=token", - "--env", "REGISTRY_AUTH_TOKEN_REALM="+fmt.Sprintf("https://%s:%d/auth", hostIP.String(), authPort), + "--env", "REGISTRY_AUTH_TOKEN_REALM=" + ta.Address + "/auth", "--env", "REGISTRY_AUTH_TOKEN_SERVICE=Docker registry", "--env", "REGISTRY_AUTH_TOKEN_ISSUER=Acme auth server", "--env", "REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/auth/domain.crt", - "--env", "REGISTRY_HTTP_TLS_CERTIFICATE=/registry/domain.crt", - "--env", "REGISTRY_HTTP_TLS_KEY=/registry/domain.key", - // rootcertbundle is not CA cert: https://github.com/distribution/distribution/issues/1143 - "-v", authCert.CertPath+":/auth/domain.crt", - "-v", registryCert.CertPath+":/registry/domain.crt", - "-v", registryCert.KeyPath+":/registry/domain.key", - testutil.RegistryImage) - cmd.AssertOK() - joined := net.JoinHostPort(hostIP.String(), strconv.Itoa(listenPort)) - if _, err = nettestutil.HTTPGet(fmt.Sprintf("https://%s/v2", joined), 30, true); err != nil { - base.Cmd("rm", "-f", registryContainerName).Run() - base.T.Fatal(err) + "-v", ta.CertPath + ":/auth/domain.crt", } - hostsDir, err := os.MkdirTemp(base.T.TempDir(), "certs.d") - assert.NilError(base.T, err) - hostsSubDir := filepath.Join(hostsDir, joined) - err = os.MkdirAll(hostsSubDir, 0700) - assert.NilError(base.T, err) - hostsTOMLPath := filepath.Join(hostsSubDir, "hosts.toml") - // See https://github.com/containerd/containerd/blob/main/docs/hosts.md - hostsTOML := fmt.Sprintf(` -server = "https://%s" -[host."https://%s"] - ca = %q - `, joined, joined, ca.CertPath) - base.T.Logf("Writing %q: %q", hostsTOMLPath, hostsTOML) - err = os.WriteFile(hostsTOMLPath, []byte(hostsTOML), 0700) - assert.NilError(base.T, err) - return &TestRegistry{ - IP: hostIP, - ListenIP: listenIP, - ListenPort: listenPort, - HostsDir: hostsDir, - Cleanup: func() { - base.Cmd("rm", "-f", registryContainerName).AssertOK() - base.Cmd("rm", "-f", authContainerName).AssertOK() - assert.NilError(base.T, registryCert.Close()) - assert.NilError(base.T, authCert.Close()) - assert.NilError(base.T, authConfigFile.Close()) - os.Remove(authConfigFileName) - }, +} + +type BasicAuth struct { + Realm string + HtFile string + Username string + Password string +} + +func (ba *BasicAuth) Params(base *testutil.Base) []string { + if ba.Realm == "" { + ba.Realm = "Basic Realm" + } + if ba.HtFile == "" && ba.Username != "" && ba.Password != "" { + pass := ba.Password + 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) + } + ret := []string{ + "--env", "REGISTRY_AUTH=htpasswd", + "--env", "REGISTRY_AUTH_HTPASSWD_REALM=" + ba.Realm, + "--env", "REGISTRY_AUTH_HTPASSWD_PATH=/htpasswd", + } + if ba.HtFile != "" { + ret = append(ret, "-v", ba.HtFile+":/htpasswd") + } + return ret +} + +func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundCleanup func(error)) *RegistryServer { + EnsureImages(base) + + name := testutil.Identifier(base.T) + // listen on 0.0.0.0 to enable 127.0.0.1 + listenIP := net.ParseIP("0.0.0.0") + hostIP, err := nettestutil.NonLoopbackIPv4() + assert.NilError(base.T, err, fmt.Errorf("failed finding ipv4 non loopback interface: %w", err)) + port, err = portlock.Acquire(port) + assert.NilError(base.T, err, fmt.Errorf("failed acquiring port: %w", err)) + + containerName := fmt.Sprintf("registry-%s-%d", name, port) + // Cleanup possible leftovers first + base.Cmd("rm", "-f", containerName).Run() + + args := []string{ + "run", + "--pull=never", + "-d", + "-p", fmt.Sprintf("%s:%d:5000", listenIP, port), + "--name", containerName, + } + scheme := "http" + var cert *testca.Cert + if ca != nil { + scheme = "https" + cert = ca.NewCert(hostIP.String(), "127.0.0.1", "localhost", "::1") + args = append(args, + "--env", "REGISTRY_HTTP_TLS_CERTIFICATE=/registry/domain.crt", + "--env", "REGISTRY_HTTP_TLS_KEY=/registry/domain.key", + "-v", cert.CertPath+":/registry/domain.crt", + "-v", cert.KeyPath+":/registry/domain.key", + ) + } + + 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) { + result := base.Cmd("rm", "-f", containerName).Run() + errPortRelease := portlock.Release(port) + var errCertClose error + if cert != nil { + errCertClose = cert.Close() + } + if boundCleanup != nil { + boundCleanup(err) + } + if cert != nil && err == nil { + assert.NilError(base.T, errCertClose, fmt.Errorf("failed cleaning certificates: %w", err)) + } + if err == nil { + assert.NilError(base.T, result.Error, fmt.Errorf("failed removing container: %w", err)) + assert.NilError(base.T, errPortRelease, fmt.Errorf("failed releasing port: %w", err)) + } + } + + hostsDir, err := func() (string, error) { + hDir, err := os.MkdirTemp(base.T.TempDir(), "certs.d") + if err != nil { + return "", err + } + + if ca != nil { + err = generateCertsd(hDir, ca.CertPath, hostIP.String(), port) + if err != nil { + return "", err + } + err = generateCertsd(hDir, ca.CertPath, "127.0.0.1", port) + if err != nil { + return "", err + } + err = generateCertsd(hDir, ca.CertPath, "localhost", port) + if err != nil { + return "", err + } + if port == 443 { + err = generateCertsd(hDir, ca.CertPath, hostIP.String(), 0) + if err != nil { + return "", err + } + err = generateCertsd(hDir, ca.CertPath, "127.0.0.1", 0) + if err != nil { + return "", err + } + err = generateCertsd(hDir, ca.CertPath, "localhost", 0) + if err != nil { + return "", err + } + } + } + + cmd := base.Cmd(args...).Run() + if cmd.Error != nil { + base.T.Logf("%s:\n%s\n%s\n-------\n%s", containerName, cmd.Cmd, cmd.Stdout(), cmd.Stderr()) + return "", cmd.Error + } + + if _, err = nettestutil.HTTPGet(fmt.Sprintf("%s://%s:%s/v2", scheme, hostIP.String(), strconv.Itoa(port)), 30, true); err != nil { + return "", err + } + + return hDir, nil + }() + + if err != nil { + cl := base.Cmd("logs", containerName).Run() + base.T.Logf("%s:\n%s\n%s\n=========================\n%s", containerName, cl.Cmd, cl.Stdout(), cl.Stderr()) + cleanup(err) + } + assert.NilError(base.T, err, fmt.Errorf("failed starting registry container in a timely manner: %w", err)) + + return &RegistryServer{ + IP: hostIP, + Port: port, + Scheme: scheme, + ListenIP: listenIP, + Cleanup: cleanup, Logs: func() { - base.T.Logf("%s: %q", registryContainerName, base.Cmd("logs", registryContainerName).Run().String()) - base.T.Logf("%s: %q", authContainerName, base.Cmd("logs", authContainerName).Run().String()) + base.T.Logf("%s: %q", containerName, base.Cmd("logs", containerName).Run().String()) }, + HostsDir: hostsDir, + } +} + +func NewWithTokenAuth(base *testutil.Base, user, pass string, port int, tls bool) *RegistryServer { + ca := testca.New(base.T) + as := NewAuthServer(base, ca, 0, user, pass, tls) + auth := &TokenAuth{ + Address: as.Scheme + "://" + net.JoinHostPort(as.IP.String(), strconv.Itoa(as.Port)), + CertPath: as.CertPath, + } + return NewRegistry(base, ca, port, auth, as.Cleanup) +} + +func NewWithNoAuth(base *testutil.Base, port int, tls bool) *RegistryServer { + var ca *testca.CA + if tls { + ca = testca.New(base.T) } + return NewRegistry(base, ca, port, &NoAuth{}, nil) } diff --git a/pkg/testutil/testsyslog/testsyslog.go b/pkg/testutil/testsyslog/testsyslog.go index 8d7b9aa6ef8..a5eeb0cbf37 100644 --- a/pkg/testutil/testsyslog/testsyslog.go +++ b/pkg/testutil/testsyslog/testsyslog.go @@ -26,8 +26,8 @@ import ( "runtime" "time" - "github.com/containerd/nerdctl/pkg/rootlessutil" - "github.com/containerd/nerdctl/pkg/testutil/testca" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/testca" ) func StartServer(n, la string, done chan<- string, certs ...*testca.Cert) (addr string, sock io.Closer) { diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 04d55b85f8a..525225fb996 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -18,6 +18,7 @@ package testutil import ( "encoding/json" + "errors" "flag" "fmt" "io" @@ -26,30 +27,40 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "time" "github.com/Masterminds/semver/v3" - "github.com/containerd/containerd/defaults" - "github.com/containerd/nerdctl/pkg/buildkitutil" - "github.com/containerd/nerdctl/pkg/imgutil" - "github.com/containerd/nerdctl/pkg/infoutil" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" - "github.com/containerd/nerdctl/pkg/inspecttypes/native" - "github.com/containerd/nerdctl/pkg/platformutil" - "github.com/containerd/nerdctl/pkg/rootlessutil" "github.com/opencontainers/go-digest" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" + + "github.com/containerd/containerd/v2/defaults" + "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/platformutil" + "github.com/containerd/nerdctl/v2/pkg/rootlessutil" ) type Base struct { - T testing.TB - Target Target - DaemonIsKillable bool - Binary string - Args []string - Env []string + T testing.TB + Target Target + DaemonIsKillable bool + EnableIPv6 bool + IPv6Compatible bool + EnableKubernetes bool + KubernetesCompatible bool + Binary string + Args []string + Env []string + Dir string } // WithStdin sets the standard input of Cmd to the specified reader @@ -62,6 +73,7 @@ func WithStdin(r io.Reader) func(*Cmd) { func (b *Base) Cmd(args ...string) *Cmd { icmdCmd := icmd.Command(b.Binary, append(b.Args, args...)...) icmdCmd.Env = b.Env + icmdCmd.Dir = b.Dir cmd := &Cmd{ Cmd: icmdCmd, Base: b, @@ -75,6 +87,7 @@ func (b *Base) ComposeCmd(args ...string) *Cmd { binaryArgs := append(b.Args, append([]string{"compose"}, args...)...) icmdCmd := icmd.Command(binary, binaryArgs...) icmdCmd.Env = b.Env + icmdCmd.Dir = b.Dir cmd := &Cmd{ Cmd: icmdCmd, Base: b, @@ -143,13 +156,13 @@ func (b *Base) systemctlArgs() []string { func (b *Base) KillDaemon() { b.T.Helper() if !b.DaemonIsKillable { - b.T.Skip("daemon is not killable (hint: set \"-test.kill-daemon\")") + b.T.Skip("daemon is not killable (hint: set \"-test.allow-kill-daemon\")") } target := b.systemctlTarget() b.T.Logf("killing %q", target) cmdKill := exec.Command("systemctl", append(b.systemctlArgs(), - []string{"kill", "-s", "KILL", target}...)...) + []string{"kill", target}...)...) if out, err := cmdKill.CombinedOutput(); err != nil { err = fmt.Errorf("cannot kill %q: %q: %w", target, string(out), err) b.T.Fatal(err) @@ -167,9 +180,7 @@ func (b *Base) EnsureDaemonActive() { sleep = 3 * time.Second ) for i := 0; i < maxRetry; i++ { - cmd := exec.Command("systemctl", - append(systemctlArgs, - []string{"is-active", target}...)...) + cmd := exec.Command("systemctl", append(systemctlArgs, "is-active", target)...) out, err := cmd.CombinedOutput() b.T.Logf("(retry=%d) %s", i, string(out)) if err == nil { @@ -191,10 +202,7 @@ func (b *Base) DumpDaemonLogs(minutes int) { b.T.Helper() target := b.systemctlTarget() cmd := exec.Command("journalctl", - append(b.systemctlArgs(), - []string{"-u", target, - "--no-pager", - "-S", fmt.Sprintf("%d min ago", minutes)}...)...) + append(b.systemctlArgs(), "-u", target, "--no-pager", "-S", fmt.Sprintf("%d min ago", minutes))...) b.T.Logf("===== %v =====", cmd.Args) out, err := cmd.CombinedOutput() if err != nil { @@ -314,14 +322,58 @@ func (b *Base) EnsureContainerStarted(con string) { b.T.Fatalf("conainer %s not running", con) } +func (b *Base) EnsureContainerExited(con string, expectedExitCode int) { + b.T.Helper() + + const ( + maxRetry = 5 + sleep = time.Second + ) + var c dockercompat.Container + for i := 0; i < maxRetry; i++ { + c = b.InspectContainer(con) + if c.State.Status == "exited" { + b.T.Logf("container %s have exited with status %d", con, c.State.ExitCode) + if c.State.ExitCode == expectedExitCode { + return + } + break + } + b.T.Logf("(retry=%d)", i+1) + time.Sleep(sleep) + } + b.T.Fatalf("expected conainer %s to have exited with code %d, got status %+v", + con, expectedExitCode, c.State) +} + type Cmd struct { icmd.Cmd *Base + runResult *icmd.Result + mu sync.Mutex } func (c *Cmd) Run() *icmd.Result { c.Base.T.Helper() - return icmd.RunCmd(c.Cmd) + c.mu.Lock() + c.runResult = icmd.RunCmd(c.Cmd) + c.mu.Unlock() + return c.runResult +} + +func (c *Cmd) runIfNecessary() *icmd.Result { + c.Base.T.Helper() + c.mu.Lock() + if c.runResult == nil { + c.runResult = icmd.RunCmd(c.Cmd) + } + c.mu.Unlock() + return c.runResult +} + +func (c *Cmd) Start() *icmd.Result { + c.Base.T.Helper() + return icmd.StartCmd(c.Cmd) } func (c *Cmd) CmdOption(cmdOptions ...func(*Cmd)) *Cmd { @@ -333,7 +385,7 @@ func (c *Cmd) CmdOption(cmdOptions ...func(*Cmd)) *Cmd { func (c *Cmd) Assert(expected icmd.Expected) { c.Base.T.Helper() - c.Run().Assert(c.Base.T, expected) + c.runIfNecessary().Assert(c.Base.T, expected) } func (c *Cmd) AssertOK() { @@ -343,14 +395,14 @@ func (c *Cmd) AssertOK() { func (c *Cmd) AssertFail() { c.Base.T.Helper() - res := c.Run() - assert.Assert(c.Base.T, res.ExitCode != 0) + res := c.runIfNecessary() + assert.Assert(c.Base.T, res.ExitCode != 0, res) } func (c *Cmd) AssertExitCode(exitCode int) { c.Base.T.Helper() - res := c.Run() - assert.Assert(c.Base.T, res.ExitCode == exitCode, res.Combined()) + res := c.runIfNecessary() + assert.Assert(c.Base.T, res.ExitCode == exitCode, res) } func (c *Cmd) AssertOutContains(s string) { @@ -361,9 +413,17 @@ func (c *Cmd) AssertOutContains(s string) { c.Assert(expected) } +func (c *Cmd) AssertErrContains(s string) { + c.Base.T.Helper() + expected := icmd.Expected{ + Err: s, + } + c.Assert(expected) +} + func (c *Cmd) AssertCombinedOutContains(s string) { c.Base.T.Helper() - res := c.Run() + res := c.runIfNecessary() assert.Assert(c.Base.T, strings.Contains(res.Combined(), s), fmt.Sprintf("expected output to contain %q: %q", s, res.Combined())) } @@ -396,6 +456,7 @@ func (c *Cmd) AssertOutContainsAny(strs ...string) { } func (c *Cmd) AssertOutNotContains(s string) { + c.Base.T.Helper() c.AssertOutWithFunc(func(stdout string) error { if strings.Contains(stdout, s) { return fmt.Errorf("expected stdout to not contain %q", s) @@ -404,6 +465,16 @@ func (c *Cmd) AssertOutNotContains(s string) { }) } +func (c *Cmd) AssertErrNotContains(s string) { + c.Base.T.Helper() + c.AssertOutWithFunc(func(stderr string) error { + if strings.Contains(stderr, s) { + return fmt.Errorf("expected stdout to not contain %q", s) + } + return nil + }) +} + func (c *Cmd) AssertOutExactly(s string) { c.Base.T.Helper() fn := func(stdout string) error { @@ -426,42 +497,31 @@ func (c *Cmd) AssertOutStreamsExactly(stdout, stderr string) { msg += fmt.Sprintf("stderr mismatch, expected %q, got %q\n", stderr, serr) } if msg != "" { - return fmt.Errorf(msg) + return errors.New(msg) } return nil } c.AssertOutStreamsWithFunc(fn) } -func (c *Cmd) AssertNoOut(s string) { - c.Base.T.Helper() - fn := func(stdout string) error { - if strings.Contains(stdout, s) { - return fmt.Errorf("expected not to contain %q, got %q", s, stdout) - } - return nil - } - c.AssertOutWithFunc(fn) -} - func (c *Cmd) AssertOutWithFunc(fn func(stdout string) error) { c.Base.T.Helper() - res := c.Run() - assert.Equal(c.Base.T, 0, res.ExitCode, res.Combined()) + res := c.runIfNecessary() + assert.Equal(c.Base.T, 0, res.ExitCode, res) assert.NilError(c.Base.T, fn(res.Stdout()), res.Combined()) } func (c *Cmd) AssertOutStreamsWithFunc(fn func(stdout, stderr string) error) { c.Base.T.Helper() - res := c.Run() - assert.Equal(c.Base.T, 0, res.ExitCode, res.Combined()) + res := c.runIfNecessary() + assert.Equal(c.Base.T, 0, res.ExitCode, res) assert.NilError(c.Base.T, fn(res.Stdout(), res.Stderr()), res.Combined()) } func (c *Cmd) Out() string { c.Base.T.Helper() - res := c.Run() - assert.Equal(c.Base.T, 0, res.ExitCode, res.Combined()) + res := c.runIfNecessary() + assert.Equal(c.Base.T, 0, res.ExitCode, res) return res.Stdout() } @@ -482,14 +542,73 @@ const ( var ( flagTestTarget Target flagTestKillDaemon bool + flagTestIPv6 bool + flagTestKube bool + flagVerbose bool + flagTestFlaky bool +) + +var ( + testLockFile = filepath.Join(os.TempDir(), "nerdctl-test-prevent-concurrency", ".lock") ) func M(m *testing.M) { flag.StringVar(&flagTestTarget, "test.target", Nerdctl, "target to test") - flag.BoolVar(&flagTestKillDaemon, "test.kill-daemon", false, "enable tests that kill the daemon") + flag.BoolVar(&flagTestKillDaemon, "test.allow-kill-daemon", false, "enable tests that kill the daemon") + flag.BoolVar(&flagTestIPv6, "test.only-ipv6", false, "enable tests on IPv6") + flag.BoolVar(&flagTestKube, "test.only-kubernetes", false, "enable tests on Kubernetes") + flag.BoolVar(&flagTestFlaky, "test.only-flaky", false, "enable testing of flaky tests only (if false, flaky tests are ignored)") + if flag.Lookup("test.v") != nil { + flagVerbose = true + } flag.Parse() - fmt.Fprintf(os.Stderr, "test target: %q\n", flagTestTarget) - os.Exit(m.Run()) + + os.Exit(func() int { + // If there is a lockfile (no err), or if we error-ed stating it (permission), another test run is currently going. + // Note that this could be racy. The .lock file COULD get acquired after this and before we hit the lock section. + // This is not a big deal then: we will just wait for the lock to free. + if _, err := os.Stat(testLockFile); err == nil || !errors.Is(err, os.ErrNotExist) { + log.L.Errorf("Another test binary is already running. If you think this is an error, manually remove %s", testLockFile) + return 1 + } + + err := os.MkdirAll(filepath.Dir(testLockFile), 0o777) + if err != nil { + log.L.WithError(err).Errorf("failed creating testing lock directory %q", filepath.Dir(testLockFile)) + return 1 + } + + // Ensure that permissions are set to 777 (regardless of umask value), so that we do not lock people out when + // switching between rootful and rootless locking + os.Chmod(filepath.Dir(testLockFile), 0o777) + + // Acquire lock + lock, err := lockutil.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) + + // Create marker file + err = os.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 + } + + // Ensure cleanup + defer func() { + os.Remove(testLockFile) + }() + + // Now, run the tests + fmt.Fprintf(os.Stderr, "test target: %q\n", flagTestTarget) + + return m.Run() + }()) } func GetTarget() string { @@ -499,12 +618,30 @@ func GetTarget() string { return flagTestTarget } +func GetEnableIPv6() bool { + return flagTestIPv6 +} + +func GetEnableKubernetes() bool { + return flagTestKube +} + +func GetFlakyEnvironment() bool { + return flagTestFlaky +} + func GetDaemonIsKillable() bool { return flagTestKillDaemon } +func IsDocker() bool { + return GetTarget() == Docker +} + +func GetVerbose() bool { return flagVerbose } + func DockerIncompatible(t testing.TB) { - if GetTarget() == Docker { + if IsDocker() { t.Skip("test is incompatible with Docker") } } @@ -618,18 +755,40 @@ func NewBaseWithNamespace(t *testing.T, ns string) *Base { if ns == "" || ns == "default" || ns == Namespace { t.Fatalf(`the other base namespace cannot be "%s"`, ns) } - return newBase(t, ns) + return newBase(t, ns, false, false) +} + +func NewBaseWithIPv6Compatible(t *testing.T) *Base { + return newBase(t, Namespace, true, false) } func NewBase(t *testing.T) *Base { - return newBase(t, Namespace) + return newBase(t, Namespace, false, false) } -func newBase(t *testing.T, ns string) *Base { +func newBase(t *testing.T, ns string, ipv6Compatible bool, kubernetesCompatible bool) *Base { base := &Base{ - T: t, - Target: GetTarget(), - DaemonIsKillable: GetDaemonIsKillable(), + T: t, + Target: GetTarget(), + DaemonIsKillable: GetDaemonIsKillable(), + EnableIPv6: GetEnableIPv6(), + IPv6Compatible: ipv6Compatible, + EnableKubernetes: GetEnableKubernetes(), + KubernetesCompatible: kubernetesCompatible, + Env: os.Environ(), + } + if base.EnableIPv6 && !base.IPv6Compatible { + t.Skip("runner skips non-IPv6 compatible tests in the IPv6 environment") + } else if !base.EnableIPv6 && base.IPv6Compatible { + t.Skip("runner skips IPv6 compatible tests in the non-IPv6 environment") + } + if base.EnableKubernetes && !base.KubernetesCompatible { + t.Skip("runner skips non-Kubernetes compatible tests in the Kubernetes environment") + } else if !base.EnableKubernetes && base.KubernetesCompatible { + t.Skip("runner skips Kubernetes compatible tests in the non-Kubernetes environment") + } + if !GetFlakyEnvironment() && !GetEnableKubernetes() && !GetEnableIPv6() { + t.Skip("legacy tests are considered flaky by default and are skipped unless in the flaky environment") } var err error switch base.Target { @@ -672,3 +831,11 @@ 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) { + t.Cleanup(func() { + NewBase(t).Cmd("builder", "prune", "--all", "--force").Run() + }) +} diff --git a/pkg/testutil/testutil_freebsd.go b/pkg/testutil/testutil_freebsd.go index 91bb973c11e..63a79f07a02 100644 --- a/pkg/testutil/testutil_freebsd.go +++ b/pkg/testutil/testutil_freebsd.go @@ -16,6 +16,8 @@ package testutil +import "fmt" + const ( CommonImage = "docker.io/knast/freebsd:13-STABLE" @@ -26,3 +28,15 @@ const ( // https://www.rfc-editor.org/rfc/rfc793 ExpectedConnectionRefusedError = "connection refused" ) + +var ( + BusyboxImage = "ghcr.io/containerd/busybox:1.36" + AlpineImage = mirrorOf("alpine:3.13") + NginxAlpineImage = mirrorOf("nginx:1.19-alpine") + GolangImage = mirrorOf("golang:1.18") +) + +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_linux.go b/pkg/testutil/testutil_linux.go index fb27e609ea2..0086a1465cb 100644 --- a/pkg/testutil/testutil_linux.go +++ b/pkg/testutil/testutil_linux.go @@ -17,6 +17,7 @@ package testutil import ( + "errors" "fmt" "io" "sync" @@ -29,17 +30,20 @@ func mirrorOf(s string) string { } var ( - BusyboxImage = "ghcr.io/containerd/busybox:1.28" + BusyboxImage = "ghcr.io/containerd/busybox:1.36" AlpineImage = mirrorOf("alpine:3.13") NginxAlpineImage = mirrorOf("nginx:1.19-alpine") NginxAlpineIndexHTMLSnippet = "Welcome to nginx!" - RegistryImage = mirrorOf("registry:2") + 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 = mirrorOf("fluent/fluentd:v1.14-1") + 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" @@ -54,10 +58,31 @@ var ( // It should be "connection refused" as per the TCP RFC. // https://www.rfc-editor.org/rfc/rfc793 ExpectedConnectionRefusedError = "connection refused" + + SigProxyTrueOut = "received SIGINT" + SigProxyTimeoutMsg = "Timed Out; No signal received" + SigProxyTestScript = `#!/bin/sh + set -eu + + sig_msg () { + printf "` + SigProxyTrueOut + `" + end + } + + trap sig_msg INT + timeout=0 + while [ $timeout -ne 10 ]; do + timeout=$((timeout+1)) + sleep 1 + done + printf "` + SigProxyTimeoutMsg + `" + end` ) const ( - FedoraESGZImage = "ghcr.io/stargz-containers/fedora:30-esgz" // eStargz + 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 ) type delayOnceReader struct { @@ -80,11 +105,32 @@ type delayOnceReader struct { // Since detaching from a container is only applicable when there is a TTY, // which usually means that there's a human in front of the computer waiting for a prompt to start typing, // it's reasonable to assume that the user will not type the detach keys before the task is started. +// +// Besides delaying the first Read() by one second, +// the returned reader also sleeps for one second if EOF is reached for the wrapped reader. +// The reason follows: +// +// NewDelayOnceReader is usually used with `unbuffer -p`, which has a caveat: +// "unbuffer simply exits when it encounters an EOF from either its input or process2." [1] +// The implication is if we use `unbuffer -p` to feed a command to container shell, +// `unbuffer -p` will exit right after it finishes reading the command (i.e., encounter an EOF from its input), +// and by that time, the container may have not executed the command and printed the wanted results to stdout, +// which would fail the test if it asserts stdout to contain certain strings. +// +// As a result, to avoid flaky tests, +// we give the container shell one second to process the command before `unbuffer -p` exits. +// +// [1] https://linux.die.net/man/1/unbuffer func NewDelayOnceReader(wrapped io.Reader) io.Reader { return &delayOnceReader{wrapped: wrapped} } func (r *delayOnceReader) Read(p []byte) (int, error) { - r.once.Do(func() { time.Sleep(time.Second) }) - return r.wrapped.Read(p) + // FIXME: this is obviously not exact science. At 1 second, it will fail regularly on the CI under load. + r.once.Do(func() { time.Sleep(5 * time.Second) }) + n, err := r.wrapped.Read(p) + if errors.Is(err, io.EOF) { + time.Sleep(time.Second) + } + return n, err } diff --git a/pkg/testutil/testutil_windows.go b/pkg/testutil/testutil_windows.go index 1f23cc78301..868e5df1cd8 100644 --- a/pkg/testutil/testutil_windows.go +++ b/pkg/testutil/testutil_windows.go @@ -17,31 +17,35 @@ package testutil import ( - "sync" - + "os" + "strconv" "strings" + "sync" + "github.com/Microsoft/hcsshim" "golang.org/x/sys/windows/svc/mgr" - "github.com/Microsoft/hcsshim" - "github.com/containerd/nerdctl/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" ) const ( - WindowsNano = "gcr.io/k8s-staging-e2e-test-images/busybox:1.29-2" - // CommonImage. // // More work needs to be done to support windows containers in test framework // for the tests that are run now this image (used in k8s upstream testing) meets the needs - // use gcr.io/k8s-staging-e2e-test-images/busybox:1.29-2-windows-amd64-ltsc2022 locally on windows 11 + // use gcr.io/k8s-staging-e2e-test-images/busybox:1.36-1-windows-amd64-ltsc2022 locally on windows 11 // https://github.com/microsoft/Windows-Containers/issues/179 - CommonImage = WindowsNano + BusyboxImage = "gcr.io/k8s-staging-e2e-test-images/busybox:1.36.1-1" + WindowsNano = BusyboxImage + CommonImage = WindowsNano // NOTE(aznashwan): the upstream e2e Nginx test image is actually based on BusyBox. NginxAlpineImage = "registry.k8s.io/e2e-test-images/nginx:1.14-2" NginxAlpineIndexHTMLSnippet = "Welcome to nginx!" + GolangImage = "fixme-test-using-this-image-is-disabled-on-windows" + AlpineImage = "fixme-test-using-this-image-is-disabled-on-windows" + // 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). @@ -60,6 +64,11 @@ var ( // HyperVSupported is a test helper to check if hyperv is enabled on // the host. This can be used to skip tests that require virtualization. func HyperVSupported() bool { + if s := os.Getenv("NO_HYPERV"); s != "" { + if b, err := strconv.ParseBool(s); err == nil && b { + return false + } + } hypervSupportedOnce.Do(func() { // Hyper-V Virtual Machine Management service name const hypervServiceName = "vmms" diff --git a/pkg/version/version.go b/pkg/version/version.go index dae44805521..91d43762670 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -16,9 +16,64 @@ package version +import ( + "runtime/debug" + "strconv" +) + var ( // Version is filled via Makefile - Version = "" + Version = "" // Revision is filled via Makefile - Revision = "" + Revision = "" ) + +const unknown = "" + +func GetVersion() string { + if Version != "" { + return Version + } + /* + * go install example.com/cmd/foo@vX.Y.Z: bi.Main.Version="vX.Y.Z", vcs.revision is unset + * go install example.com/cmd/foo@latest: bi.Main.Version="vX.Y.Z", vcs.revision is unset + * go install example.com/cmd/foo@master: bi.Main.Version="vX.Y.Z-N.yyyyMMddhhmmss-gggggggggggg", vcs.revision is unset + * go install ./cmd/foo: bi.Main.Version="(devel)", vcs.revision="gggggggggggggggggggggggggggggggggggggggg" + * vcs.time="yyyy-MM-ddThh:mm:ssZ", vcs.modified=("false"|"true") + */ + if bi, ok := debug.ReadBuildInfo(); ok { + if bi.Main.Version != "" && bi.Main.Version != "(devel)" { + return bi.Main.Version + } + } + return unknown +} + +func GetRevision() string { + if Revision != "" { + return Revision + } + if bi, ok := debug.ReadBuildInfo(); ok { + var ( + vcsRevision string + vcsModified bool + ) + for _, f := range bi.Settings { + switch f.Key { + case "vcs.revision": + vcsRevision = f.Value + case "vcs.modified": + vcsModified, _ = strconv.ParseBool(f.Value) + } + } + if vcsRevision == "" { + return unknown + } + rev := vcsRevision + if vcsModified { + rev += ".m" + } + return rev + } + return unknown +}