diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 41b9b0ceb2e..9cdc031c6b9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ env: jobs: go: timeout-minutes: 5 - name: "go | ${{ matrix.goos }} | ${{ matrix.canary }}" + name: "${{ matrix.goos }} | ${{ matrix.canary }}" runs-on: "${{ matrix.os }}" defaults: run: @@ -42,7 +42,7 @@ jobs: - name: Set GO env run: | # If canary is specified, get the latest available golang pre-release instead of the major version - if [ "$canary" != "" ]; then + if [ "${{ matrix.canary }}" != "" ]; then . ./hack/build-integration-canary.sh canary::golang::latest fi diff --git a/.github/workflows/tigron.yml b/.github/workflows/tigron.yml new file mode 100644 index 00000000000..e238c659fe9 --- /dev/null +++ b/.github/workflows/tigron.yml @@ -0,0 +1,90 @@ +name: tigron + +on: + push: + branches: + - main + - 'release/**' + pull_request: + paths: 'mod/tigron/**' + +env: + GO_VERSION: 1.24.x + GOTOOLCHAIN: local + +jobs: + lint: + timeout-minutes: 10 + name: "${{ matrix.goos }} ${{ matrix.os }} | go ${{ matrix.canary }}" + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + strategy: + matrix: + include: + - os: ubuntu-24.04 + - os: macos-15 + - os: windows-2022 + - os: ubuntu-24.04 + goos: freebsd + - os: ubuntu-24.04 + canary: go-canary + steps: + - name: "Checkout project" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 100 + - name: "Set GO env" + run: | + # If canary is specified, get the latest available golang pre-release instead of the major version + if [ "${{ matrix.canary }}" != "" ]; then + . ./hack/build-integration-canary.sh + canary::golang::latest + fi + - name: "Install go" + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + - name: "Install tools" + run: | + cd mod/tigron + echo "::group:: make install-dev-tools" + make install-dev-tools + if [ "$RUNNER_OS" == macOS ]; then + brew install yamllint shellcheck + fi + echo "::endgroup::" + - name: "lint" + env: + NO_COLOR: true + run: | + if [ "$RUNNER_OS" != "Linux" ] || [ "${{ matrix.goos }}" != "" ]; then + echo "It is not necessary to run the linter on this platform (${{ env.RUNNER_OS }} ${{ matrix.goos }})" + exit + fi + + echo "::group:: lint" + cd mod/tigron + export LINT_COMMIT_RANGE="$(jq -r '.after + "..HEAD"' ${GITHUB_EVENT_PATH})" + make lint + echo "::endgroup::" + - name: "test-unit" + run: | + echo "::group:: unit test" + cd mod/tigron + make test-unit + echo "::endgroup::" + - name: "test-unit-race" + run: | + echo "::group:: race test" + cd mod/tigron + make test-unit-race + echo "::endgroup::" + - name: "test-unit-bench" + run: | + echo "::group:: bench" + cd mod/tigron + make test-unit-bench + echo "::endgroup::" diff --git a/cmd/nerdctl/builder/builder_build_test.go b/cmd/nerdctl/builder/builder_build_test.go index be8bc051a67..e4999a927ee 100644 --- a/cmd/nerdctl/builder/builder_build_test.go +++ b/cmd/nerdctl/builder/builder_build_test.go @@ -102,7 +102,7 @@ CMD ["echo", "nerdctl-build-test-string"]`, testutil.CommonImage) Cleanup: func(data test.Data, helpers test.Helpers) { helpers.Anyhow("rmi", "-f", data.Identifier()) }, - Expected: test.Expects(-1, nil, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), }, }, } @@ -234,7 +234,7 @@ func TestBuildFromStdin(t *testing.T) { 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)) + cmd.Feed(strings.NewReader(dockerfile)) return cmd }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { diff --git a/cmd/nerdctl/builder/builder_builder_test.go b/cmd/nerdctl/builder/builder_builder_test.go index 57c1a864ee3..a049e566380 100644 --- a/cmd/nerdctl/builder/builder_builder_test.go +++ b/cmd/nerdctl/builder/builder_builder_test.go @@ -83,7 +83,7 @@ CMD ["echo", "nerdctl-builder-debug-test-string"]`, testutil.CommonImage) 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"))) + cmd.Feed(bytes.NewReader([]byte("c\n"))) return cmd }, Expected: test.Expects(0, nil, nil), diff --git a/cmd/nerdctl/container/container_attach_linux_test.go b/cmd/nerdctl/container/container_attach_linux_test.go index d2f5a7b7b0d..083fd4a194e 100644 --- a/cmd/nerdctl/container/container_attach_linux_test.go +++ b/cmd/nerdctl/container/container_attach_linux_test.go @@ -17,8 +17,9 @@ package container import ( + "bytes" "errors" - "os" + "io" "strings" "testing" "time" @@ -56,11 +57,9 @@ func TestAttach(t *testing.T) { testCase.Setup = func(data test.Data, helpers test.Helpers) { cmd := helpers.Command("run", "--rm", "-it", "--name", data.Identifier(), testutil.CommonImage) - cmd.WithPseudoTTY(func(f *os.File) error { - // ctrl+p and ctrl+q (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{16, 17}) - return err - }) + cmd.WithPseudoTTY() + // ctrl+p and ctrl+q (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) + cmd.Feed(bytes.NewReader([]byte{16, 17})) cmd.Run(&test.Expected{ ExitCode: 0, @@ -74,15 +73,15 @@ func TestAttach(t *testing.T) { testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { // Run interactively and detach cmd := helpers.Command("attach", data.Identifier()) - cmd.WithPseudoTTY(func(f *os.File) error { - _, _ = f.WriteString("echo mark${NON}mark\n") + + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo mark${NON}mark\n")) + cmd.WithFeeder(func() io.Reader { // Interestingly, and unlike with run, on attach, docker (like nerdctl) ALSO needs a pause so that the // container can read stdin before we detach time.Sleep(time.Second) // ctrl+p and ctrl+q (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{16, 17}) - - return err + return bytes.NewReader([]byte{16, 17}) }) return cmd @@ -120,10 +119,8 @@ func TestAttachDetachKeys(t *testing.T) { testCase.Setup = func(data test.Data, helpers test.Helpers) { cmd := helpers.Command("run", "--rm", "-it", "--detach-keys=ctrl-q", "--name", data.Identifier(), testutil.CommonImage) - cmd.WithPseudoTTY(func(f *os.File) error { - _, err := f.Write([]byte{17}) - return err - }) + cmd.WithPseudoTTY() + cmd.Feed(bytes.NewReader([]byte{17})) cmd.Run(&test.Expected{ ExitCode: 0, @@ -137,15 +134,14 @@ func TestAttachDetachKeys(t *testing.T) { testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { // Run interactively and detach cmd := helpers.Command("attach", "--detach-keys=ctrl-a,ctrl-b", data.Identifier()) - cmd.WithPseudoTTY(func(f *os.File) error { - _, _ = f.WriteString("echo mark${NON}mark\n") + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo mark${NON}mark\n")) + cmd.WithFeeder(func() io.Reader { // Interestingly, and unlike with run, on attach, docker (like nerdctl) ALSO needs a pause so that the // container can read stdin before we detach time.Sleep(time.Second) - // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{1, 2}) - - return err + // ctrl+p and ctrl+q (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) + return bytes.NewReader([]byte{1, 2}) }) return cmd @@ -179,11 +175,9 @@ func TestAttachForAutoRemovedContainer(t *testing.T) { testCase.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) - cmd.WithPseudoTTY(func(f *os.File) error { - // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{1, 2}) - return err - }) + cmd.WithPseudoTTY() + // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) + cmd.Feed(bytes.NewReader([]byte{1, 2})) cmd.Run(&test.Expected{ ExitCode: 0, @@ -197,10 +191,8 @@ func TestAttachForAutoRemovedContainer(t *testing.T) { testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { // Run interactively and detach cmd := helpers.Command("attach", data.Identifier()) - cmd.WithPseudoTTY(func(f *os.File) error { - _, err := f.WriteString("echo mark${NON}mark\nexit 42\n") - return err - }) + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo mark${NON}mark\nexit 42\n")) return cmd } diff --git a/cmd/nerdctl/container/container_inspect_linux_test.go b/cmd/nerdctl/container/container_inspect_linux_test.go index 3617f37e273..c03a0a472ea 100644 --- a/cmd/nerdctl/container/container_inspect_linux_test.go +++ b/cmd/nerdctl/container/container_inspect_linux_test.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/v2/pkg/infoutil" "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" "github.com/containerd/nerdctl/v2/pkg/labels" diff --git a/cmd/nerdctl/container/container_run.go b/cmd/nerdctl/container/container_run.go index d42d61e1d2f..12b2ac9ad82 100644 --- a/cmd/nerdctl/container/container_run.go +++ b/cmd/nerdctl/container/container_run.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/containerd/console" "github.com/containerd/log" @@ -407,7 +408,7 @@ func runAction(cmd *cobra.Command, args []string) error { return err } defer con.Reset() - if err := con.SetRaw(); err != nil { + if _, err := term.MakeRaw(int(con.Fd())); err != nil { return err } } diff --git a/cmd/nerdctl/container/container_run_linux_test.go b/cmd/nerdctl/container/container_run_linux_test.go index efffd92196f..378464bfcbd 100644 --- a/cmd/nerdctl/container/container_run_linux_test.go +++ b/cmd/nerdctl/container/container_run_linux_test.go @@ -379,6 +379,7 @@ func TestRunSigProxy(t *testing.T) { }, Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // FIXME: os.Interrupt will likely not work on Windows cmd := nerdtest.RunSigProxyContainer(os.Interrupt, true, nil, data, helpers) err := cmd.Signal(os.Interrupt) assert.NilError(helpers.T(), err) @@ -417,7 +418,7 @@ func TestRunSigProxy(t *testing.T) { return cmd }, - Expected: test.Expects(127, nil, expect.DoesNotContain(nerdtest.SignalCaught)), + Expected: test.Expects(expect.ExitCodeSignaled, nil, expect.DoesNotContain(nerdtest.SignalCaught)), }, } @@ -503,8 +504,9 @@ func TestRunWithDetachKeys(t *testing.T) { testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { // Run interactively and detach cmd := helpers.Command("run", "-it", "--detach-keys=ctrl-a,ctrl-b", "--name", data.Identifier(), testutil.CommonImage) - cmd.WithPseudoTTY(func(f *os.File) error { - _, _ = f.WriteString("echo mark${NON}mark\n") + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo mark${NON}mark\n")) + cmd.WithFeeder(func() io.Reader { // Because of the way we proxy stdin, we have to wait here, otherwise we detach before // the rest of the input ever reaches the container // Note that this only concerns nerdctl, as docker seems to behave ok LOCALLY. @@ -514,8 +516,7 @@ func TestRunWithDetachKeys(t *testing.T) { nerdtest.EnsureContainerStarted(helpers, data.Identifier()) // } // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{1, 2}) - return err + return bytes.NewReader([]byte{1, 2}) }) return cmd @@ -571,8 +572,9 @@ func TestIssue3568(t *testing.T) { testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { // Run interactively and detach cmd := helpers.Command("run", "--rm", "-it", "--detach-keys=ctrl-a,ctrl-b", "--name", data.Identifier(), testutil.CommonImage) - cmd.WithPseudoTTY(func(f *os.File) error { - _, _ = f.WriteString("echo mark${NON}mark\n") + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("echo mark${NON}mark\n")) + cmd.WithFeeder(func() io.Reader { // Because of the way we proxy stdin, we have to wait here, otherwise we detach before // the rest of the input ever reaches the container // Note that this only concerns nerdctl, as docker seems to behave ok LOCALLY. @@ -582,8 +584,7 @@ func TestIssue3568(t *testing.T) { nerdtest.EnsureContainerStarted(helpers, data.Identifier()) // } // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{1, 2}) - return err + return bytes.NewReader([]byte{1, 2}) }) return cmd diff --git a/cmd/nerdctl/container/container_start_linux_test.go b/cmd/nerdctl/container/container_start_linux_test.go index afd7d1d788b..4f56cc9d679 100644 --- a/cmd/nerdctl/container/container_start_linux_test.go +++ b/cmd/nerdctl/container/container_start_linux_test.go @@ -17,8 +17,9 @@ package container import ( + "bytes" "errors" - "os" + "io" "strings" "testing" @@ -40,10 +41,8 @@ func TestStartDetachKeys(t *testing.T) { testCase.Setup = func(data test.Data, helpers test.Helpers) { cmd := helpers.Command("run", "-it", "--name", data.Identifier(), testutil.CommonImage) - cmd.WithPseudoTTY(func(f *os.File) error { - _, err := f.WriteString("exit\n") - return err - }) + cmd.WithPseudoTTY() + cmd.Feed(strings.NewReader("exit\n")) cmd.Run(&test.Expected{ ExitCode: 0, }) @@ -60,10 +59,10 @@ func TestStartDetachKeys(t *testing.T) { flags += "i" } cmd := helpers.Command("start", flags, "--detach-keys=ctrl-a,ctrl-b", data.Identifier()) - cmd.WithPseudoTTY(func(f *os.File) error { + cmd.WithPseudoTTY() + cmd.WithFeeder(func() io.Reader { // ctrl+a and ctrl+b (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes) - _, err := f.Write([]byte{1, 2}) - return err + return bytes.NewReader([]byte{1, 2}) }) return cmd diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go index 38b0b034fed..8956d2d9f77 100644 --- a/cmd/nerdctl/image/image_list_test.go +++ b/cmd/nerdctl/image/image_list_test.go @@ -270,13 +270,13 @@ RUN echo "actually creating a layer so that docker sets the createdAt time" 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), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("No such image: ")}, nil), }, { Description: "before=non-exists-image", Require: nerdtest.NerdctlNeedsFixing("https://github.com/containerd/nerdctl/issues/3511"), Command: test.Command("images", "--filter", "before=non-exists-image"), - Expected: test.Expects(-1, []error{errors.New("No such image: ")}, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("No such image: ")}, nil), }, }, } diff --git a/cmd/nerdctl/image/image_load_test.go b/cmd/nerdctl/image/image_load_test.go index 3533c183e84..fc1fa549da4 100644 --- a/cmd/nerdctl/image/image_load_test.go +++ b/cmd/nerdctl/image/image_load_test.go @@ -53,7 +53,7 @@ func TestLoadStdinFromPipe(t *testing.T) { 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) + cmd.Feed(reader) return cmd }, Expected: func(data test.Data, helpers test.Helpers) *test.Expected { diff --git a/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go index aa36f35b08e..2387555c0a3 100644 --- a/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go +++ b/cmd/nerdctl/ipfs/ipfs_compose_linux_test.go @@ -19,6 +19,7 @@ package ipfs import ( "fmt" "io" + "os" "strconv" "strings" "testing" @@ -211,8 +212,9 @@ func TestIPFSCompBuild(t *testing.T) { // 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) + // This should not take longer than that + ipfsServer.WithTimeout(30 * time.Second) + ipfsServer.Background() // Apparently necessary to let it start... time.Sleep(time.Second) @@ -237,9 +239,8 @@ COPY index.html /usr/share/nginx/html/index.html 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) + ipfsServer.Signal(os.Kill) } if comp != nil { helpers.Anyhow("compose", "-f", comp.YAMLFullPath(), "down", "-v") diff --git a/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go index 20865efc834..dcb0f7429ed 100644 --- a/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go +++ b/cmd/nerdctl/ipfs/ipfs_registry_linux_test.go @@ -70,8 +70,9 @@ func TestIPFSNerdctlRegistry(t *testing.T) { // 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) + // This should not take longer than that + ipfsServer.WithTimeout(30 * time.Second) + ipfsServer.Background() // Apparently necessary to let it start... time.Sleep(time.Second) } @@ -79,7 +80,7 @@ func TestIPFSNerdctlRegistry(t *testing.T) { testCase.Cleanup = func(data test.Data, helpers test.Helpers) { if ipfsServer != nil { // Close the server once done - ipfsServer.Run(nil) + ipfsServer.Signal(os.Kill) } } diff --git a/cmd/nerdctl/issues/main_linux_test.go b/cmd/nerdctl/issues/main_linux_test.go index 4703897a7e2..12f1b9384d6 100644 --- a/cmd/nerdctl/issues/main_linux_test.go +++ b/cmd/nerdctl/issues/main_linux_test.go @@ -39,7 +39,7 @@ func TestIssue108(t *testing.T) { { 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 := helpers.Command("run", "--quiet", "-it", "--rm", "--net=host", testutil.CommonImage, "echo", "this was always working") cmd.WithPseudoTTY() return cmd }, @@ -48,7 +48,7 @@ func TestIssue108(t *testing.T) { { 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 := helpers.Command("run", "--quiet", "--rm", "--net=host", "-it", testutil.CommonImage, "echo", "this was not working due to issue #108") cmd.WithPseudoTTY() return cmd }, diff --git a/cmd/nerdctl/main_test_test.go b/cmd/nerdctl/main_test_test.go index 36c6a96e3a9..ae9acc0f7f6 100644 --- a/cmd/nerdctl/main_test_test.go +++ b/cmd/nerdctl/main_test_test.go @@ -61,7 +61,7 @@ func TestTest(t *testing.T) { { Description: "failure with multiple error testing", Command: test.Command("-fail"), - Expected: test.Expects(-1, []error{errors.New("unknown"), errors.New("shorthand")}, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("unknown"), errors.New("shorthand")}, nil), }, { Description: "success with exact output testing", diff --git a/cmd/nerdctl/system/system_events_linux_test.go b/cmd/nerdctl/system/system_events_linux_test.go index 20f1f8e3f06..bcf01bd0f19 100644 --- a/cmd/nerdctl/system/system_events_linux_test.go +++ b/cmd/nerdctl/system/system_events_linux_test.go @@ -30,7 +30,9 @@ import ( 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) + // 3 seconds is too short on slow rig (EL8) + cmd.WithTimeout(10 * time.Second) + cmd.Background() helpers.Ensure("run", "--rm", testutil.CommonImage) return cmd } diff --git a/cmd/nerdctl/volume/volume_create_test.go b/cmd/nerdctl/volume/volume_create_test.go index 8e761c6e015..8eedd781751 100644 --- a/cmd/nerdctl/volume/volume_create_test.go +++ b/cmd/nerdctl/volume/volume_create_test.go @@ -85,7 +85,7 @@ func TestVolumeCreate(t *testing.T) { helpers.Anyhow("volume", "rm", "-f", data.Identifier()) }, // NOTE: docker returns 125 on this - Expected: test.Expects(-1, []error{errdefs.ErrInvalidArgument}, nil), + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errdefs.ErrInvalidArgument}, nil), }, { Description: "creating already existing volume should succeed", diff --git a/mod/tigron/.golangci.yml b/mod/tigron/.golangci.yml index 2395687bac8..4e312afa579 100644 --- a/mod/tigron/.golangci.yml +++ b/mod/tigron/.golangci.yml @@ -30,7 +30,6 @@ linters: - github.com/creack/pty - golang.org/x/sync - golang.org/x/term - - gotest.tools/v3 - go.uber.org/goleak staticcheck: checks: diff --git a/mod/tigron/doc.go b/mod/tigron/doc.go new file mode 100644 index 00000000000..29b88771e7a --- /dev/null +++ b/mod/tigron/doc.go @@ -0,0 +1,20 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +/* +Package tigron is a testing framework for command-line binaries. +*/ +package main diff --git a/mod/tigron/expect/comparators.go b/mod/tigron/expect/comparators.go index d6c6c8731f5..17edc209588 100644 --- a/mod/tigron/expect/comparators.go +++ b/mod/tigron/expect/comparators.go @@ -17,6 +17,7 @@ package expect import ( + "encoding/hex" "fmt" "regexp" "strings" @@ -45,7 +46,8 @@ func Contains(compare string) test.Comparator { return func(stdout, info string, t *testing.T) { t.Helper() assertive.Check(t, strings.Contains(stdout, compare), - fmt.Sprintf("Output does not contain: %q", compare)+info) + fmt.Sprintf("Output does not contain: %q", compare), + info) } } @@ -56,7 +58,7 @@ func DoesNotContain(compare string) test.Comparator { return func(stdout, info string, t *testing.T) { t.Helper() assertive.Check(t, !strings.Contains(stdout, compare), - fmt.Sprintf("Output should not contain: %q", compare)+info) + fmt.Sprintf("Output should not contain: %q", compare), info) } } @@ -65,10 +67,14 @@ func Equals(compare string) test.Comparator { //nolint:thelper return func(stdout, info string, t *testing.T) { t.Helper() + + hexdump := hex.Dump([]byte(stdout)) assertive.Check( t, compare == stdout, - fmt.Sprintf("Output is not equal to: %q", compare)+info, + fmt.Sprintf("Output is not equal to: %q", compare), + "\n"+hexdump, + info, ) } } diff --git a/mod/tigron/expect/consts.go b/mod/tigron/expect/exit.go similarity index 83% rename from mod/tigron/expect/consts.go rename to mod/tigron/expect/exit.go index 2abab2235b2..4ebdf0df594 100644 --- a/mod/tigron/expect/consts.go +++ b/mod/tigron/expect/exit.go @@ -21,9 +21,12 @@ const ( ExitCodeSuccess = 0 // ExitCodeGenericFail will verify that the command ran and exited with a non-zero error code. // This does NOT include timeouts, cancellation, or signals. - ExitCodeGenericFail = -1 + ExitCodeGenericFail = -10 // ExitCodeNoCheck does not enforce any check at all on the function. - ExitCodeNoCheck = -2 + ExitCodeNoCheck = -11 // ExitCodeTimeout verifies that the command was cancelled on timeout. - ExitCodeTimeout = -3 + ExitCodeTimeout = -12 + // ExitCodeSignaled verifies that the command has been terminated by a signal. + ExitCodeSignaled = -13 + // ExitCodeCancelled = -14. ) diff --git a/mod/tigron/go.mod b/mod/tigron/go.mod index 8e81eb56a19..882205f71db 100644 --- a/mod/tigron/go.mod +++ b/mod/tigron/go.mod @@ -1,16 +1,12 @@ module github.com/containerd/nerdctl/mod/tigron -go 1.23 +go 1.23.0 require ( github.com/creack/pty v1.1.24 go.uber.org/goleak v1.3.0 golang.org/x/sync v0.11.0 golang.org/x/term v0.29.0 - gotest.tools/v3 v3.5.2 ) -require ( - github.com/google/go-cmp v0.6.0 // indirect - golang.org/x/sys v0.30.0 // indirect -) +require golang.org/x/sys v0.31.0 // indirect diff --git a/mod/tigron/go.sum b/mod/tigron/go.sum index a38084b4f4c..bb62fe3d0ed 100644 --- a/mod/tigron/go.sum +++ b/mod/tigron/go.sum @@ -2,8 +2,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= @@ -12,11 +10,9 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 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/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/mod/tigron/internal/com/command.go b/mod/tigron/internal/com/command.go new file mode 100644 index 00000000000..1db8d79b135 --- /dev/null +++ b/mod/tigron/internal/com/command.go @@ -0,0 +1,441 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package com + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/containerd/nerdctl/mod/tigron/internal/logger" +) + +const ( + defaultTimeout = 10 * time.Second + delayAfterWait = 100 * time.Millisecond +) + +var ( + // ErrTimeout is returned by Wait() in case a command fail to complete within allocated time. + ErrTimeout = errors.New("command timed out") + // ErrFailedStarting is returned by Run() and Wait() in case a command fails to start (eg: + // binary missing). + ErrFailedStarting = errors.New("command failed starting") + // ErrSignaled is returned by Wait() if a signal was sent to the command while running. + ErrSignaled = errors.New("command execution signaled") + // ErrExecutionFailed is returned by Wait() when a command executes but returns a non-zero error + // code. + ErrExecutionFailed = errors.New("command returned a non-zero exit code") + // ErrFailedSendingSignal may happen if sending a signal to an already terminated process. + ErrFailedSendingSignal = errors.New("failed sending signal") + + // ErrExecAlreadyStarted is a system error normally indicating a bogus double call to Run(). + ErrExecAlreadyStarted = errors.New("command has already been started (double `Run`)") + // ErrExecNotStarted is a system error normally indicating that Wait() has been called without + // first calling Run(). + ErrExecNotStarted = errors.New("command has not been started (call `Run` first)") + // ErrExecAlreadyFinished is a system error indicating a double call to Wait(). + ErrExecAlreadyFinished = errors.New("command is already finished") + + errExecutionCancelled = errors.New("command execution cancelled") +) + +type contextKey string + +// LoggerKey defines the key to attach a logger to on the context. +const LoggerKey = contextKey("logger") + +// Result carries the resulting output of a command once it has finished. +type Result struct { + Environ []string + Stdout string + Stderr string + ExitCode int + Signal os.Signal +} + +type execution struct { + //nolint:containedctx + context context.Context + cancel context.CancelFunc + command *exec.Cmd + pipes *stdPipes + log logger.Logger + err error +} + +// Command is a thin wrapper on-top of golang exec.Command. +type Command struct { + Binary string + PrependArgs []string + Args []string + WrapBinary string + WrapArgs []string + Timeout time.Duration + + WorkingDir string + Env map[string]string + // FIXME: EnvBlackList might change for a better mechanism (regexp and/or whitelist + blacklist) + EnvBlackList []string + + writers []func() io.Reader + + ptyStdout bool + ptyStderr bool + ptyStdin bool + + exec *execution + mutex sync.Mutex + result *Result +} + +// Clone does just duplicate a command, resetting its execution. +func (gc *Command) Clone() *Command { + com := &Command{ + Binary: gc.Binary, + PrependArgs: append([]string(nil), gc.PrependArgs...), + Args: append([]string(nil), gc.Args...), + WrapBinary: gc.WrapBinary, + WrapArgs: append([]string(nil), gc.WrapArgs...), + Timeout: gc.Timeout, + + WorkingDir: gc.WorkingDir, + Env: map[string]string{}, + EnvBlackList: append([]string(nil), gc.EnvBlackList...), + + writers: append([]func() io.Reader(nil), gc.writers...), + + ptyStdout: gc.ptyStdout, + ptyStderr: gc.ptyStderr, + ptyStdin: gc.ptyStdin, + } + + for k, v := range gc.Env { + com.Env[k] = v + } + + return com +} + +// WithPTY requests that the command be executed with a pty for std streams. Parameters allow +// showing which streams +// are to be tied to the pty. +// This command has no effect if Run has already been called. +func (gc *Command) WithPTY(stdin, stdout, stderr bool) { + gc.ptyStdout = stdout + gc.ptyStderr = stderr + gc.ptyStdin = stdin +} + +// WithFeeder ensures that the provider function will be executed and its output fed to the command +// stdin. WithFeeder, like Feed, can be used multiple times, and writes will be performed +// sequentially, in order. +// This command has no effect if Run has already been called. +func (gc *Command) WithFeeder(writers ...func() io.Reader) { + gc.writers = append(gc.writers, writers...) +} + +// Feed ensures that the provider reader will be copied on the command stdin. +// Feed, like WithFeeder, can be used multiple times, and writes will be performed in sequentially, +// in order. +// This command has no effect if Run has already been called. +func (gc *Command) Feed(reader io.Reader) { + gc.writers = append(gc.writers, func() io.Reader { + return reader + }) +} + +// Run starts the command in the background. +// It may error out immediately if the command fails to start (ErrFailedStarting). +func (gc *Command) Run(parentCtx context.Context) error { + // Lock + gc.mutex.Lock() + defer gc.mutex.Unlock() + + // Protect against dumb calls + if gc.result != nil { + return ErrExecAlreadyFinished + } else if gc.exec != nil { + return ErrExecAlreadyStarted + } + + var ( + ctx context.Context + ctxCancel context.CancelFunc + pipes *stdPipes + cmd *exec.Cmd + err error + ) + + // Get a timing-out context + timeout := gc.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + ctx, ctxCancel = context.WithTimeout(parentCtx, timeout) + + // Create a contextual command, set the logger + cmd = gc.buildCommand(ctx) + + // Get a debug-logger from the context + var ( + log logger.Logger + ok bool + ) + + if log, ok = parentCtx.Value(LoggerKey).(logger.Logger); !ok { + log = nil + } + + // FIXME: this is manual silencing - should be possible to enable this with some debug flag + conLog := logger.NewLogger(log).Set("command", cmd.String()) + emLog := logger.NewLogger(nil).Set("command", cmd.String()) + + gc.exec = &execution{ + context: ctx, + cancel: ctxCancel, + command: cmd, + log: conLog, + } + + // Prepare pipes + pipes, err = newStdPipes(ctx, emLog, gc.ptyStdout, gc.ptyStderr, gc.ptyStdin, gc.writers) + if err != nil { + ctxCancel() + + gc.exec.err = errors.Join(ErrFailedStarting, err) + + // No wrapping here - we do not even have pipes, and the command has not been started. + + return gc.exec.err + } + + // Attach pipes + gc.exec.pipes = pipes + cmd.Stdout = pipes.stdout.writer + cmd.Stderr = pipes.stderr.writer + cmd.Stdin = pipes.stdin.reader + + // Start it + if err = cmd.Start(); err != nil { + // On failure, can the context, wrap whatever we have and return + gc.exec.log.Log("start failed", err) + + gc.exec.err = errors.Join(ErrFailedStarting, err) + + _ = gc.wrap() + + defer ctxCancel() + + return gc.exec.err + } + + select { + case <-ctx.Done(): + // There is no good reason for this to happen, so, log it + err = gc.wrap() + + gc.exec.log.Log("stdout", gc.result.Stdout) + gc.exec.log.Log("stderr", gc.result.Stderr) + gc.exec.log.Log("exitcode", gc.result.ExitCode) + gc.exec.log.Log("err", err) + gc.exec.log.Log("ctxerr", ctx.Err()) + + return err + default: + } + + return nil +} + +// Wait should be called after Run(), and will return the outcome of the command execution. +func (gc *Command) Wait() (*Result, error) { + gc.mutex.Lock() + defer gc.mutex.Unlock() + + switch { + case gc.exec == nil: + return nil, ErrExecNotStarted + case gc.exec.err != nil: + return gc.result, gc.exec.err + case gc.result != nil: + return gc.result, ErrExecAlreadyFinished + } + + // Cancel the context in any case now + defer gc.exec.cancel() + + // Wait for the command + _ = gc.exec.command.Wait() + + // Capture timeout and cancellation + select { + case <-gc.exec.context.Done(): + default: + } + + // Wrap the results and return + err := gc.wrap() + + return gc.result, err +} + +// Signal sends a signal to the command. It should be called after Run() but before Wait(). +func (gc *Command) Signal(sig os.Signal) error { + gc.mutex.Lock() + defer gc.mutex.Unlock() + + if gc.exec == nil { + return ErrExecNotStarted + } + + err := gc.exec.command.Process.Signal(sig) + if err != nil { + err = errors.Join(ErrFailedSendingSignal, err) + } + + return err +} + +func (gc *Command) wrap() error { + pipes := gc.exec.pipes + cmd := gc.exec.command + ctx := gc.exec.context + + // Close and drain the pipes + pipes.closeCallee() + _ = pipes.ioGroup.Wait() + pipes.closeCaller() + + // Get the status, exitCode, signal, error + var ( + status syscall.WaitStatus + signal os.Signal + exitCode int + err error + ) + + // XXXgolang: this is troubling. cmd.ProcessState.ExitCode() is always fine, even if + // cmd.ProcessState is nil. + exitCode = cmd.ProcessState.ExitCode() + + if cmd.ProcessState != nil { + var ok bool + if status, ok = cmd.ProcessState.Sys().(syscall.WaitStatus); !ok { + panic("failed casting process state sys") + } + + if status.Signaled() { + signal = status.Signal() + err = ErrSignaled + } else if exitCode != 0 { + err = ErrExecutionFailed + } + } + + // Catch-up on the context + switch ctx.Err() { + case context.DeadlineExceeded: + err = ErrTimeout + case context.Canceled: + err = errExecutionCancelled + default: + } + + // Stuff everything in Result and return err + gc.result = &Result{ + ExitCode: exitCode, + Stdout: pipes.fromStdout, + Stderr: pipes.fromStderr, + Environ: cmd.Environ(), + Signal: signal, + } + + if gc.exec.err == nil { + gc.exec.err = err + } + + return gc.exec.err +} + +func (gc *Command) buildCommand(ctx context.Context) *exec.Cmd { + // Build arguments and binary + args := gc.Args + if gc.PrependArgs != nil { + args = append(gc.PrependArgs, args...) + } + + binary := gc.Binary + + if gc.WrapBinary != "" { + args = append([]string{gc.Binary}, args...) + args = append(gc.WrapArgs, args...) + binary = gc.WrapBinary + } + + //nolint:gosec + cmd := exec.CommandContext(ctx, binary, args...) + + // Add dir + cmd.Dir = gc.WorkingDir + + // Set wait delay after waits returns + cmd.WaitDelay = delayAfterWait + + // Build env + cmd.Env = []string{} + // TODO: replace with regexps? and/or whitelist? + for _, envValue := range os.Environ() { + add := true + + for _, b := range gc.EnvBlackList { + if b == "*" || strings.HasPrefix(envValue, b+"=") { + add = false + + break + } + } + + if add { + cmd.Env = append(cmd.Env, envValue) + } + } + + // Attach any explicit env we have + for k, v := range gc.Env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + // Attach platform ProcAttr and get optional custom cancellation routine + if cancellation := addAttr(cmd); cancellation != nil { + cmd.Cancel = func() error { + gc.exec.log.Log("command cancelled") + + // Call the platform dependent cancellation routine + return cancellation() + } + } + + return cmd +} diff --git a/mod/tigron/internal/com/command_other.go b/mod/tigron/internal/com/command_other.go new file mode 100644 index 00000000000..7bddc09c9ff --- /dev/null +++ b/mod/tigron/internal/com/command_other.go @@ -0,0 +1,39 @@ +//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 com + +import ( + "os/exec" + "syscall" +) + +func addAttr(cmd *exec.Cmd) func() error { + // Default shutdown will leave child processes behind in certain circumstances + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + // FIXME: understand why we would want that + // Setctty: true, + } + + return func() error { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + + return nil + } +} diff --git a/mod/tigron/internal/com/command_test.go b/mod/tigron/internal/com/command_test.go new file mode 100644 index 00000000000..04034f037b8 --- /dev/null +++ b/mod/tigron/internal/com/command_test.go @@ -0,0 +1,624 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package com_test + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/internal/assertive" + "github.com/containerd/nerdctl/mod/tigron/internal/com" +) + +const windows = "windows" + +// Testing faulty code (double run, etc.) + +func TestFaultyDoubleRunWait(t *testing.T) { + // Double run returns an error on the second run, but Wait will still work properly + t.Parallel() + + command := &com.Command{ + Binary: "printf", + Args: []string{"one"}, + Timeout: time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + err = command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIs(t, err, com.ErrExecAlreadyStarted) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, expect.ExitCodeSuccess, res.ExitCode) + assertive.IsEqual(t, "one", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) +} + +func TestFaultyRunDoubleWait(t *testing.T) { + // Double wait returns an error on the second wait, but also returns the existing result + t.Parallel() + + command := &com.Command{ + Binary: "printf", + Args: []string{"one"}, + Timeout: time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, expect.ExitCodeSuccess, res.ExitCode) + assertive.IsEqual(t, "one", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) + + res, err = command.Wait() + + assertive.ErrorIs(t, err, com.ErrExecAlreadyFinished) + assertive.IsEqual(t, expect.ExitCodeSuccess, res.ExitCode) + assertive.IsEqual(t, "one", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) +} + +func TestFailRun(t *testing.T) { + t.Parallel() + + command := &com.Command{ + Binary: "does-not-exist", + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIs(t, err, com.ErrFailedStarting) + + err = command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIs(t, err, com.ErrExecAlreadyFinished) + + res, err := command.Wait() + + assertive.ErrorIs(t, err, com.ErrFailedStarting) + assertive.IsEqual(t, -1, res.ExitCode) + assertive.IsEqual(t, "", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) + + res, err = command.Wait() + + assertive.ErrorIs(t, err, com.ErrFailedStarting) + assertive.IsEqual(t, -1, res.ExitCode) + assertive.IsEqual(t, "", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) +} + +func TestBasicRunWait(t *testing.T) { + t.Parallel() + + command := &com.Command{ + Binary: "printf", + Args: []string{"one"}, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, 0, res.ExitCode) + assertive.IsEqual(t, "one", res.Stdout) + assertive.IsEqual(t, "", res.Stderr) +} + +func TestBasicFail(t *testing.T) { + t.Parallel() + + command := &com.Command{ + Binary: "bash", + Args: []string{"-c", "--", "does-not-exist"}, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIs(t, err, com.ErrExecutionFailed) + assertive.IsEqual(t, 127, res.ExitCode) + assertive.IsEqual(t, "", res.Stdout) + assertive.StringHasSuffix(t, res.Stderr, "does-not-exist: command not found\n") +} + +func TestWorkingDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + command := &com.Command{ + Binary: "pwd", + WorkingDir: dir, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, 0, res.ExitCode) + + // Note: + // - darwin will link to /private/DIR, so, check with HasSuffix + // - windows+ming will go to C:\Users\RUNNER~1\AppData\Local\Temp\, so, ignore Windows + if runtime.GOOS == windows { + t.Skip("skipping last check on windows, see note") + } + + assertive.StringHasSuffix(t, res.Stdout, dir+"\n") +} + +func TestEnvBlacklist(t *testing.T) { + t.Setenv("FOO", "BAR") + t.Setenv("FOOBAR", "BARBAR") + + command := &com.Command{ + Binary: "env", + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, 0, res.ExitCode) + assertive.StringContains(t, res.Stdout, "FOO=BAR") + assertive.StringContains(t, res.Stdout, "FOOBAR=BARBAR") + + command = &com.Command{ + Binary: "env", + EnvBlackList: []string{"FOO"}, + } + + err = command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err = command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.StringDoesNotContain(t, res.Stdout, "FOO=BAR") + assertive.StringContains(t, res.Stdout, "FOOBAR=BARBAR") + + // On windows, with mingw, SYSTEMROOT,TERM and HOME (possibly others) will be forcefully added + // to the environment regardless, so, we can't test "*" blacklist + if runtime.GOOS == windows { + t.Skip( + "Windows/mingw will always repopulate the environment with extra variables we cannot bypass", + ) + } + + command = &com.Command{ + Binary: "env", + EnvBlackList: []string{"*"}, + } + + err = command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err = command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.IsEqual(t, res.Stdout, "") +} + +func TestEnvAdd(t *testing.T) { + t.Setenv("FOO", "BAR") + t.Setenv("BLED", "BLED") + t.Setenv("BAZ", "OLD") + + command := &com.Command{ + Binary: "env", + Env: map[string]string{ + "FOO": "REPLACE", + "BAR": "NEW", + "BLED": "EXPLICIT", + }, + EnvBlackList: []string{"BLED"}, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.StringContains(t, res.Stdout, "FOO=REPLACE") + assertive.StringContains(t, res.Stdout, "BAR=NEW") + assertive.StringContains(t, res.Stdout, "BAZ=OLD") + assertive.StringContains(t, res.Stdout, "BLED=EXPLICIT") +} + +func TestStdoutStderr(t *testing.T) { + t.Parallel() + + command := &com.Command{ + Binary: "bash", + Args: []string{"-c", "--", "printf onstdout; >&2 printf onstderr;"}, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.IsEqual(t, res.Stdout, "onstdout") + assertive.IsEqual(t, res.Stderr, "onstderr") +} + +func TestTimeoutPlain(t *testing.T) { + t.Parallel() + + start := time.Now() + command := &com.Command{ + Binary: "bash", + // XXX unclear if windows is really able to terminate sleep 5, so, split it up to give it a + // chance... + Args: []string{"-c", "--", "printf one; sleep 1; sleep 1; sleep 1; sleep 1; printf two"}, + Timeout: 1 * time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + end := time.Now() + + assertive.ErrorIs(t, err, com.ErrTimeout) + assertive.IsEqual(t, res.ExitCode, -1) + assertive.IsEqual(t, res.Stdout, "one") + assertive.IsEqual(t, res.Stderr, "") + assertive.DurationIsLessThan(t, end.Sub(start), 2*time.Second) +} + +func TestTimeoutDelayed(t *testing.T) { + t.Parallel() + + start := time.Now() + command := &com.Command{ + Binary: "bash", + // XXX unclear if windows is really able to terminate sleep 5, so, split it up to give it a + // chance... + Args: []string{"-c", "--", "printf one; sleep 1; sleep 1; sleep 1; sleep 1; printf two"}, + Timeout: 1 * time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + time.Sleep(1 * time.Second) + + res, err := command.Wait() + + end := time.Now() + + assertive.ErrorIs(t, err, com.ErrTimeout) + assertive.IsEqual(t, res.ExitCode, -1) + assertive.IsEqual(t, res.Stdout, "one") + assertive.IsEqual(t, res.Stderr, "") + assertive.DurationIsLessThan(t, end.Sub(start), 2*time.Second) +} + +func TestPTYStdout(t *testing.T) { + t.Parallel() + + if runtime.GOOS == windows { + t.Skip("PTY are not supported on Windows") + } + + command := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", + "--", + "[ -t 1 ] || { echo not a pty; exit 41; }; printf onstdout; >&2 printf onstderr;", + }, + Timeout: 1 * time.Second, + } + + command.WithPTY(false, true, false) + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.IsEqual(t, res.Stdout, "onstdout") + assertive.IsEqual(t, res.Stderr, "onstderr") +} + +func TestPTYStderr(t *testing.T) { + t.Parallel() + + if runtime.GOOS == windows { + t.Skip("PTY are not supported on Windows") + } + + command := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", + "--", + "[ -t 2 ] || { echo not a pty; exit 41; }; printf onstdout; >&2 printf onstderr;", + }, + Timeout: 1 * time.Second, + } + + command.WithPTY(false, false, true) + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.IsEqual(t, res.Stdout, "onstdout") + assertive.IsEqual(t, res.Stderr, "onstderr") +} + +func TestPTYBoth(t *testing.T) { + t.Parallel() + + if runtime.GOOS == windows { + t.Skip("PTY are not supported on Windows") + } + + command := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", "--", "[ -t 1 ] && [ -t 2 ] || { echo not a pty; exit 41; }; printf onstdout; >&2 printf onstderr;", + }, + Timeout: 1 * time.Second, + } + + command.WithPTY(true, true, true) + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, res.ExitCode, 0) + assertive.IsEqual(t, res.Stdout, "onstdoutonstderr") + assertive.IsEqual(t, res.Stderr, "") +} + +func TestWriteStdin(t *testing.T) { + t.Parallel() + + command := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", "--", + "read line1; read line2; read line3; printf 'from stdin%s%s%s' \"$line1\" \"$line2\" \"$line3\";", + }, + Timeout: 1 * time.Second, + } + + command.WithFeeder(func() io.Reader { + time.Sleep(100 * time.Millisecond) + + return strings.NewReader("hello first\n") + }) + + command.Feed(strings.NewReader("hello world\n")) + command.Feed(strings.NewReader("hello again\n")) + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIsNil(t, err) + assertive.IsEqual(t, 0, res.ExitCode) + assertive.IsEqual(t, "from stdinhello firsthello worldhello again", res.Stdout) +} + +func TestWritePTYStdin(t *testing.T) { + t.Parallel() + + if runtime.GOOS == windows { + t.Skip("PTY are not supported on Windows") + } + + command := &com.Command{ + Binary: "bash", + Args: []string{"-c", "--", "[ -t 0 ] || { echo not a pty; exit 41; }; cat /dev/stdin"}, + Timeout: 1 * time.Second, + } + + command.WithPTY(true, false, false) + + command.WithFeeder(func() io.Reader { + time.Sleep(100 * time.Millisecond) + + return strings.NewReader("hello first") + }) + + command.Feed(strings.NewReader("hello world")) + command.Feed(strings.NewReader("hello again")) + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIs(t, err, com.ErrTimeout) + assertive.IsEqual(t, -1, res.ExitCode) + assertive.IsEqual(t, "hello firsthello worldhello again", res.Stdout) +} + +func TestSignalOnCompleted(t *testing.T) { + t.Parallel() + + var usig os.Signal = syscall.SIGTERM + + command := &com.Command{ + Binary: "true", + Timeout: 3 * time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + _, err = command.Wait() + + assertive.ErrorIsNil(t, err) + + err = command.Signal(usig) + + assertive.ErrorIs(t, err, com.ErrFailedSendingSignal) +} + +// FIXME: this is not working as expected, and proc.Signal returns nil error while it should not. +// func TestSignalTooLate(t *testing.T) { +// t.Parallel() +// +// var usig os.Signal +// usig = syscall.SIGTERM +// +// command := &com.Command{ +// Binary: "true", +// Timeout: 3 * time.Second, +// } +// +// err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) +// +// assertive.ErrorIsNil(t, err) +// +// time.Sleep(1 * time.Second) +// +// err = command.Signal(usig) +// +// assertive.ErrorIs(t, err, com.ErrFailedSendingSignal) +// } + +func TestSignalNormal(t *testing.T) { + t.Parallel() + + var usig os.Signal = syscall.SIGTERM + + sig, ok := usig.(syscall.Signal) + if !ok { + panic("sig cast failed") + } + + command := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", "--", + fmt.Sprintf( + "printf entry; sig_msg () { printf \"caught\"; exit 42; }; trap sig_msg %s; "+ + "printf set; while true; do sleep 0.1; done", + strconv.Itoa(int(sig)), + ), + }, + Timeout: 3 * time.Second, + } + + err := command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + // A bit arbitrary - just want to wait for stdout to go through before sending the signal + time.Sleep(100 * time.Millisecond) + + _ = command.Signal(usig) + + assertive.ErrorIsNil(t, err) + + res, err := command.Wait() + + assertive.ErrorIs(t, err, com.ErrExecutionFailed) + assertive.IsEqual(t, res.Stdout, "entrysetcaught") + assertive.IsEqual(t, res.Stderr, "") + assertive.IsEqual(t, res.ExitCode, 42) + assertive.True(t, res.Signal == nil) + + command = &com.Command{ + Binary: "sleep", + Args: []string{"10"}, + Timeout: 3 * time.Second, + } + + err = command.Run(context.WithValue(context.Background(), com.LoggerKey, t)) + + assertive.ErrorIsNil(t, err) + + err = command.Signal(usig) + + assertive.ErrorIsNil(t, err) + + res, err = command.Wait() + + assertive.ErrorIs(t, err, com.ErrSignaled) + assertive.IsEqual(t, res.Stdout, "") + assertive.IsEqual(t, res.Stderr, "") + assertive.IsEqual(t, res.Signal, usig) + assertive.IsEqual(t, res.ExitCode, -1) +} diff --git a/mod/tigron/internal/com/command_windows.go b/mod/tigron/internal/com/command_windows.go new file mode 100644 index 00000000000..32a66084a00 --- /dev/null +++ b/mod/tigron/internal/com/command_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 com + +import ( + "os/exec" +) + +func addAttr(_ *exec.Cmd) func() error { + return nil +} diff --git a/mod/tigron/internal/com/doc.go b/mod/tigron/internal/com/doc.go new file mode 100644 index 00000000000..f7b6764de37 --- /dev/null +++ b/mod/tigron/internal/com/doc.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 com is a lightweight wrapper around golang command execution. +// It provides a simplified API to create commands with baked-in: +// - timeout +// - pty +// - environment filtering +// - stdin manipulation +// - proper termination of the process group +// - wrapping commands and prepended args +package com diff --git a/mod/tigron/internal/com/package_benchmark_test.go b/mod/tigron/internal/com/package_benchmark_test.go new file mode 100644 index 00000000000..de3fdad8c42 --- /dev/null +++ b/mod/tigron/internal/com/package_benchmark_test.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 com_test + +import ( + "context" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/internal/com" +) + +// FIXME: this requires go 1.24 - uncomment when go 1.23 is out of support +// func BenchmarkCommand(b *testing.B) { +// for b.Loop() { +// cmd := com.Command{ +// Binary: "true", +// } +// +// _ = cmd.Run() +// _, _ = cmd.Wait() +// } +// } + +func BenchmarkCommandParallel(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + cmd := &com.Command{ + Binary: "true", + } + _ = cmd.Run(context.Background()) + _, _ = cmd.Wait() + } + }) +} diff --git a/mod/tigron/internal/com/package_example_test.go b/mod/tigron/internal/com/package_example_test.go new file mode 100644 index 00000000000..38df0249fef --- /dev/null +++ b/mod/tigron/internal/com/package_example_test.go @@ -0,0 +1,262 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package com_test + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/containerd/nerdctl/mod/tigron/internal/com" +) + +func ExampleCommand() { + cmd := com.Command{ + Binary: "printf", + Args: []string{"hello world"}, + } + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + exec, err := cmd.Wait() + if err != nil { + fmt.Println("Wait err:", err) + + return + } + + fmt.Println("Exit code:", exec.ExitCode) + fmt.Println("Stdout:") + fmt.Println(exec.Stdout) + fmt.Println("Stderr:") + fmt.Println(exec.Stderr) + + // Output: + // Exit code: 0 + // Stdout: + // hello world + // Stderr: + // +} + +func ExampleCommand_Signal() { + cmd := com.Command{ + Binary: "sleep", + Args: []string{"3600"}, + Timeout: time.Second, + } + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + err = cmd.Signal(os.Interrupt) + if err != nil { + fmt.Println("Signal err:", err) + + return + } + + exec, err := cmd.Wait() + fmt.Println("Wait err:", err) + fmt.Println("Exit code:", exec.ExitCode) + fmt.Println("Stdout:") + fmt.Println(exec.Stdout) + fmt.Println("Stderr:") + fmt.Println(exec.Stderr) + fmt.Println("Signal:", exec.Signal) + + // Output: + // Wait err: command execution signaled + // Exit code: -1 + // Stdout: + // + // Stderr: + // + // Signal: interrupt +} + +func ExampleCommand_WithPTY() { + cmd := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", + "--", + "[ -t 1 ] || { echo not a pty; exit 41; }; printf onstdout; >&2 printf onstderr;", + }, + Timeout: 1 * time.Second, + } + + // The PTY can be set to any of stdin, stdout, stderr + // Note that PTY are supported only on Linux, Darwin and FreeBSD + cmd.WithPTY(false, true, false) + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + exec, err := cmd.Wait() + if err != nil { + fmt.Println("Wait err:", err) + + return + } + + fmt.Println("Exit code:", exec.ExitCode) + fmt.Println("Stdout:") + fmt.Println(exec.Stdout) + fmt.Println("Stderr:") + fmt.Println(exec.Stderr) + + // Output: + // Exit code: 0 + // Stdout: + // onstdout + // Stderr: + // onstderr +} + +func ExampleCommand_Feed() { + cmd := &com.Command{ + Binary: "bash", + Args: []string{ + "-c", "--", + "read line1; read line2; printf 'from stdin%s%s%s' \"$line1\" \"$line2\";", + }, + } + + // Use WithFeeder if you do want to perform additional tasks before feeding to stdin + cmd.WithFeeder(func() io.Reader { + time.Sleep(100 * time.Millisecond) + + return strings.NewReader("hello world\n") + }) + + // Or use the simpler Feed if you just want to pass along content to stdin + // Note that successive calls to WithFeeder / Feed will be written to stdin in order. + cmd.Feed(strings.NewReader("hello again\n")) + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + exec, err := cmd.Wait() + if err != nil { + fmt.Println("Wait err:", err) + + return + } + + fmt.Println("Exit code:", exec.ExitCode) + fmt.Println("Stdout:") + fmt.Println(exec.Stdout) + fmt.Println("Stderr:") + fmt.Println(exec.Stderr) + + // Output: + // Exit code: 0 + // Stdout: + // from stdinhello worldhello again + // Stderr: + // +} + +func ExampleErrTimeout() { + cmd := &com.Command{ + Binary: "sleep", + Args: []string{"3600"}, + Timeout: time.Second, + } + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + exec, err := cmd.Wait() + fmt.Println("Wait err:", err) + fmt.Println("Exit code:", exec.ExitCode) + fmt.Println("Stdout:") + fmt.Println(exec.Stdout) + fmt.Println("Stderr:") + fmt.Println(exec.Stderr) + + // Output: + // Wait err: command timed out + // Exit code: -1 + // Stdout: + // + // Stderr: + // +} + +func ExampleErrFailedStarting() { + cmd := &com.Command{ + Binary: "non-existent", + } + + err := cmd.Run(context.Background()) + + fmt.Println("Run err:") + fmt.Println(err) + + // Output: + // Run err: + // command failed starting + // exec: "non-existent": executable file not found in $PATH +} + +func ExampleErrExecutionFailed() { + cmd := &com.Command{ + Binary: "bash", + Args: []string{"-c", "--", "does-not-exist"}, + } + + err := cmd.Run(context.Background()) + if err != nil { + fmt.Println("Run err:", err) + + return + } + + exec, err := cmd.Wait() + fmt.Println("Wait err:", err) + fmt.Println("Exit code:", exec.ExitCode) + + // Output: + // Wait err: command returned a non-zero exit code + // Exit code: 127 +} diff --git a/mod/tigron/internal/com/package_test.go b/mod/tigron/internal/com/package_test.go new file mode 100644 index 00000000000..1b6c1c5d526 --- /dev/null +++ b/mod/tigron/internal/com/package_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 com_test + +import ( + "fmt" + "os" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/internal/highk" +) + +func TestMain(m *testing.M) { + // Prep exit code + exitCode := 0 + defer func() { os.Exit(exitCode) }() + + var ( + snapFile *os.File + before, after []byte + ) + + if os.Getenv("HIGHK_EXPERIMENTAL_FD") != "" { + snapFile, _ = os.CreateTemp("", "fileleaks") + before, _ = highk.SnapshotOpenFiles(snapFile) + } + + exitCode = m.Run() + + if exitCode != 0 { + return + } + + if os.Getenv("HIGHK_EXPERIMENTAL_FD") != "" { + after, _ = highk.SnapshotOpenFiles(snapFile) + diff := highk.Diff(string(before), string(after)) + + if len(diff) != 0 { + _, _ = fmt.Fprintln(os.Stderr, "Leaking file descriptors") + + for _, file := range diff { + _, _ = fmt.Fprintln(os.Stderr, file) + } + + exitCode = 1 + } + } + + if err := highk.FindGoRoutines(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Leaking go routines") + _, _ = fmt.Fprintln(os.Stderr, err.Error()) + + exitCode = 1 + } +} diff --git a/mod/tigron/internal/com/pipes.go b/mod/tigron/internal/com/pipes.go new file mode 100644 index 00000000000..fc8f9e32baf --- /dev/null +++ b/mod/tigron/internal/com/pipes.go @@ -0,0 +1,270 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package com + +import ( + "bytes" + "context" + "errors" + "io" + "os" + + "golang.org/x/sync/errgroup" + "golang.org/x/term" + + "github.com/containerd/nerdctl/mod/tigron/internal/logger" + "github.com/containerd/nerdctl/mod/tigron/internal/pty" +) + +var ( + // ErrFailedCreating could be returned by newStdPipes() on pty creation failure. + ErrFailedCreating = errors.New("failed acquiring pipe") + // ErrFailedReading could be returned by the ioGroup in case the go routines fails to read out + // of a pipe. + ErrFailedReading = errors.New("failed reading") + // ErrFailedWriting could be returned by the ioGroup in case the go routines fails to write on a + // pipe. + ErrFailedWriting = errors.New("failed writing") +) + +type pipe struct { + reader io.ReadCloser + writer io.WriteCloser +} + +type stdPipes struct { + ioGroup *errgroup.Group + stdin *pipe + stdout *pipe + stderr *pipe + fromStdout string + fromStderr string + log logger.Logger +} + +func (pipes *stdPipes) closeCallee() { + // Failure to close will happen: + // 1. on a "normal" context timeout: + // - command is cancelled first (which forcibly closes the callee pipes) + // - then context deadline is hit + // - then closeCallee is called here + // 2. if we have a pty attached on both stdout and stderr + pipes.log.Helper() + pipes.log.Log("<- closing callee pipes") + + if pipes.stdin.reader != nil { + if closeErr := pipes.stdin.reader.Close(); closeErr != nil { + pipes.log.Log(" x failed closing callee stdin", closeErr) + } + } + + if pipes.stdout.writer != nil { + if closeErr := pipes.stdout.writer.Close(); closeErr != nil { + pipes.log.Log(" x failed closing callee stdout", closeErr) + } + } + + if pipes.stderr.writer != nil { + if closeErr := pipes.stderr.writer.Close(); closeErr != nil { + pipes.log.Log(" x failed closing callee stderr", closeErr) + } + } +} + +func (pipes *stdPipes) closeCaller() { + pipes.log.Helper() + pipes.log.Log("<- closing caller pipes") + + if pipes.stdin.writer != nil { + if closeErr := pipes.stdin.writer.Close(); closeErr != nil { + pipes.log.Log(" x failed closing caller stdin", closeErr) + } + } + + if pipes.stdout.reader != nil { + if closeErr := pipes.stdout.reader.Close(); closeErr != nil { + pipes.log.Log(" x failed closing caller stdout", closeErr) + } + } + + if pipes.stderr.reader != nil { + if closeErr := pipes.stderr.reader.Close(); closeErr != nil { + pipes.log.Log(" x failed closing caller stderr", closeErr) + } + } +} + +//nolint:gocognit +func newStdPipes( + ctx context.Context, + log *logger.ConcreteLogger, + ptyStdout, ptyStderr, ptyStdin bool, + writers []func() io.Reader, +) (pipes *stdPipes, err error) { + // Close everything cleanly in case we errored + defer func() { + if err != nil { + pipes.closeCallee() + pipes.closeCaller() + } + }() + + log = log.Set(">", "pipes") + pipes = &stdPipes{ + stdin: &pipe{}, + stdout: &pipe{}, + stderr: &pipe{}, + log: log, + } + + var ( + mty *os.File + tty *os.File + ) + + // If we want a pty, configure it now + if ptyStdout || ptyStderr || ptyStdin { + pipes.log.Log("<- opening pty") + + mty, tty, err = pty.Open() + if err != nil { + pipes.log.Log(" x failed opening pty", err) + + return nil, errors.Join(ErrFailedCreating, err) + } + + if _, err = term.MakeRaw(int(tty.Fd())); err != nil { + pipes.log.Log(" x failed making pty raw", err) + + return nil, errors.Join(ErrFailedCreating, err) + } + } + + if ptyStdin { + pipes.log.Log("<- assigning pty to stdin") + + pipes.stdin.writer = mty + pipes.stdin.reader = tty + } else if len(writers) > 0 { + pipes.log.Log(" * assigning a pipe to stdin as we have writers") + + // Only create a pipe for stdin if we intend on writing to stdin. + // Otherwise, processes awaiting end of stream will just hang there. + pipes.stdin.reader, pipes.stdin.writer, err = os.Pipe() + if err != nil { + pipes.log.Log(" x failed creating pipe for stdin", err) + + return nil, errors.Join(ErrFailedCreating, err) + } + } + + if ptyStdout { + pipes.log.Log("<- assigning pty to stdout") + + pipes.stdout.writer = tty + pipes.stdout.reader = mty + } else { + pipes.stdout.reader, pipes.stdout.writer, err = os.Pipe() + if err != nil { + pipes.log.Log(" x failed creating pipe for stdout", err) + + return nil, errors.Join(ErrFailedCreating, err) + } + } + + if ptyStderr { + pipes.log.Log("<- assigning pty to stderr") + + pipes.stderr.writer = tty + pipes.stderr.reader = mty + } else { + pipes.stderr.reader, pipes.stderr.writer, err = os.Pipe() + if err != nil { + pipes.log.Log(" x failed creating pipe for stderr", err) + + return nil, errors.Join(ErrFailedCreating, err) + } + } + + // Prepare ioGroup + pipes.ioGroup, _ = errgroup.WithContext(ctx) + + // Writers to stdin + pipes.ioGroup.Go(func() error { + pipes.log.Log("-> about to write to stdin") + + for _, writer := range writers { + if _, copyErr := io.Copy(pipes.stdin.writer, writer()); copyErr != nil { + pipes.log.Log(" x failed writing to stdin", copyErr) + + return errors.Join(ErrFailedWriting, copyErr) + } + } + + pipes.log.Log("<- done writing to stdin") + + if !ptyStdin && pipes.stdin.writer != nil { + if closeErr := pipes.stdin.writer.Close(); closeErr != nil { + pipes.log.Log(" x failed closing caller stdin", closeErr) + } + } + + return nil + }) + + // Read stdout... + pipes.ioGroup.Go(func() error { + pipes.log.Log("-> about to read stdout") + + buf := &bytes.Buffer{} + _, copyErr := io.Copy(buf, pipes.stdout.reader) + pipes.fromStdout = buf.String() + + if copyErr != nil { + pipes.log.Log(" x failed reading from stdout", copyErr) + + copyErr = errors.Join(ErrFailedReading, copyErr) + } + + pipes.log.Log("<- done reading stdout") + + return copyErr + }) + + // ... and stderr (if not the same - eg: pty) + if pipes.stderr.reader != pipes.stdout.reader { + pipes.ioGroup.Go(func() error { + pipes.log.Log("-> about to read stderr") + + buf := &bytes.Buffer{} + _, copyErr := io.Copy(buf, pipes.stderr.reader) + pipes.fromStderr = buf.String() + + if copyErr != nil { + pipes.log.Log(" x failed reading from stderr", copyErr) + + copyErr = errors.Join(ErrFailedReading, copyErr) + } + + pipes.log.Log("<- done reading stderr") + + return copyErr + }) + } + + return pipes, nil +} diff --git a/mod/tigron/test/internal/consts.go b/mod/tigron/internal/exit.go similarity index 77% rename from mod/tigron/test/internal/consts.go rename to mod/tigron/internal/exit.go index 2fcb2680c87..3fddd602491 100644 --- a/mod/tigron/test/internal/consts.go +++ b/mod/tigron/internal/exit.go @@ -17,13 +17,16 @@ // Package internal provides an assert library, pty, a command wrapper, and a leak detection library // for internal use in Tigron. // The objective for these is not to become generic use-cases libraries, but instead to deliver what -// Tigron needs in the simplest possible form. +// Tigron needs +// in the simplest possible form. package internal -// This is duplicated form `expect` to avoid circular imports. +// This is duplicated from `expect` to avoid circular imports. const ( ExitCodeSuccess = 0 - ExitCodeGenericFail = -1 - ExitCodeNoCheck = -2 - ExitCodeTimeout = -3 + ExitCodeGenericFail = -10 + ExitCodeNoCheck = -11 + ExitCodeTimeout = -12 + ExitCodeSignaled = -13 + // ExitCodeCancelled = -14. ) diff --git a/mod/tigron/internal/logger/doc.go b/mod/tigron/internal/logger/doc.go new file mode 100644 index 00000000000..29cbb608fd6 --- /dev/null +++ b/mod/tigron/internal/logger/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 logger is a very simple stub allowing developers to hook whatever logger they want to +// debug internal behavior of the com package. +// The passed logger just has to implement the Log(args...interface{}) method. +// Typically, that would be testing.T. +package logger diff --git a/mod/tigron/internal/logger/logger.go b/mod/tigron/internal/logger/logger.go new file mode 100644 index 00000000000..7fa26e5b718 --- /dev/null +++ b/mod/tigron/internal/logger/logger.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 logger + +import ( + "time" +) + +// Logger describes a passed logger, useful only for debugging. +type Logger interface { + Log(args ...interface{}) + Helper() +} + +// ConcreteLogger is a simple struct allowing to set additional metadata for a Logger. +type ConcreteLogger struct { + meta []interface{} + wrappedLog Logger +} + +// Set allows attaching metadata to the logger display. +func (cl *ConcreteLogger) Set(key, value string) *ConcreteLogger { + return &ConcreteLogger{ + meta: append(cl.meta, "["+key+"="+value+"]"), + wrappedLog: cl.wrappedLog, + } +} + +// Log prints a message using the Log method of the embedded Logger. +func (cl *ConcreteLogger) Log(args ...interface{}) { + if cl.wrappedLog != nil { + cl.wrappedLog.Helper() + cl.wrappedLog.Log( + append( + append([]interface{}{"[" + time.Now().Format(time.RFC3339) + "]"}, cl.meta...), + args...)...) + } +} + +// Helper is called so that traces from t.Log are not linking to the logger methods themselves. +func (cl *ConcreteLogger) Helper() { + if cl.wrappedLog != nil { + cl.wrappedLog.Helper() + } +} + +// NewLogger returns a new concrete logger from a struct satisfying the Logger interface. +func NewLogger(logger Logger) *ConcreteLogger { + return &ConcreteLogger{ + wrappedLog: logger, + } +} diff --git a/mod/tigron/test/internal/pty/pty.go b/mod/tigron/internal/pty/pty.go similarity index 100% rename from mod/tigron/test/internal/pty/pty.go rename to mod/tigron/internal/pty/pty.go diff --git a/mod/tigron/test/case.go b/mod/tigron/test/case.go index e72e13a7c6b..3ec3574a4d7 100644 --- a/mod/tigron/test/case.go +++ b/mod/tigron/test/case.go @@ -111,7 +111,7 @@ func (test *Case) Run(t *testing.T) { var custCom CustomizableCommand if registeredTestable == nil { - custCom = &GenericCommand{} + custCom = NewGenericCommand() } else { custCom = registeredTestable.CustomCommand(test, test.t) } diff --git a/mod/tigron/test/command.go b/mod/tigron/test/command.go index 05c7a2d798e..a8ed09e4524 100644 --- a/mod/tigron/test/command.go +++ b/mod/tigron/test/command.go @@ -18,7 +18,7 @@ package test import ( - "bytes" + "context" "fmt" "io" "os" @@ -26,18 +26,19 @@ import ( "testing" "time" - "golang.org/x/sync/errgroup" - "golang.org/x/term" - "gotest.tools/v3/icmd" - + "github.com/containerd/nerdctl/mod/tigron/internal" "github.com/containerd/nerdctl/mod/tigron/internal/assertive" - "github.com/containerd/nerdctl/mod/tigron/test/internal" - "github.com/containerd/nerdctl/mod/tigron/test/internal/pty" + "github.com/containerd/nerdctl/mod/tigron/internal/com" ) +const defaultExecutionTimeout = 3 * time.Minute + // CustomizableCommand is an interface meant for people who want to heavily customize the base -// command -// of their test case. +// command of their test case. +// FIXME: now that most of the logic got moved to the internal command, consider simplifying this / +// removing some of the extra layers from here +// +//nolint:interfacebloat type CustomizableCommand interface { TestableCommand @@ -45,6 +46,8 @@ type CustomizableCommand interface { // WithBlacklist allows to filter out unwanted variables from the embedding environment - // default it pass any that is defined by WithEnv WithBlacklist(env []string) + // T returns the current testing object + T() *testing.T // withEnv *copies* the passed map to the environment of the command to be executed // Note that this will override any variable defined in the embedding environment @@ -54,10 +57,10 @@ type CustomizableCommand interface { // 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 does a clone, but will clear binary and arguments while retaining the env, or any other + // custom properties Gotcha: if genericCommand is embedded with a custom Run and an overridden + // clear to return the embedding type the result will be the embedding command, no longer the + // genericCommand clear() TestableCommand // Will manipulate specific configuration option on the command @@ -68,6 +71,19 @@ type CustomizableCommand interface { read(key ConfigKey) ConfigValue } +//nolint:ireturn +func NewGenericCommand() CustomizableCommand { + genericCom := &GenericCommand{ + Env: map[string]string{}, + cmd: &com.Command{}, + } + + genericCom.cmd.Env = genericCom.Env + genericCom.cmd.Timeout = defaultExecutionTimeout + + return genericCom +} + // GenericCommand is a concrete Command implementation. type GenericCommand struct { Config Config @@ -76,164 +92,155 @@ type GenericCommand struct { t *testing.T - helperBinary string - helperArgs []string - prependArgs []string - mainBinary string - mainArgs []string - - envBlackList []string - stdin io.Reader - async bool - pty bool - ptyWriters []func(*os.File) error - timeout time.Duration - workingDir string - - result *icmd.Result + cmd *com.Command + async bool + rawStdErr string } func (gc *GenericCommand) WithBinary(binary string) { - gc.mainBinary = binary + gc.cmd.Binary = binary } func (gc *GenericCommand) WithArgs(args ...string) { - gc.mainArgs = append(gc.mainArgs, args...) + gc.cmd.Args = append(gc.cmd.Args, args...) } func (gc *GenericCommand) WithWrapper(binary string, args ...string) { - gc.helperBinary = binary - gc.helperArgs = args + gc.cmd.WrapBinary = binary + gc.cmd.WrapArgs = args } -func (gc *GenericCommand) WithPseudoTTY(writers ...func(*os.File) error) { - gc.pty = true - gc.ptyWriters = writers +func (gc *GenericCommand) WithPseudoTTY() { + gc.cmd.WithPTY(true, true, false) } -func (gc *GenericCommand) WithStdin(r io.Reader) { - gc.stdin = r +func (gc *GenericCommand) Feed(r io.Reader) { + gc.cmd.Feed(r) +} + +func (gc *GenericCommand) WithFeeder(fun func() io.Reader) { + gc.cmd.WithFeeder(fun) } func (gc *GenericCommand) WithCwd(path string) { - gc.workingDir = path + gc.cmd.WorkingDir = path } -func (gc *GenericCommand) Run(expect *Expected) { - if gc.t != nil { - gc.t.Helper() - } +func (gc *GenericCommand) WithBlacklist(env []string) { + gc.cmd.EnvBlackList = env +} + +func (gc *GenericCommand) WithTimeout(timeout time.Duration) { + gc.cmd.Timeout = timeout +} - var ( - result *icmd.Result - env []string - tty *os.File - psty *os.File - stdout string - ) +func (gc *GenericCommand) PrependArgs(args ...string) { + gc.cmd.PrependArgs = args +} - output := &bytes.Buffer{} - copyGroup := &errgroup.Group{} +func (gc *GenericCommand) Background() { + gc.async = true - //nolint:nestif - if !gc.async { - iCmdCmd := gc.boot() - - if gc.pty { - psty, tty, _ = pty.Open() - _, _ = term.MakeRaw(int(tty.Fd())) - - iCmdCmd.Stdin = tty - iCmdCmd.Stdout = tty - - // Copy from the master - copyGroup.Go(func() error { - _, _ = io.Copy(output, psty) - - return nil - }) - - // Cautiously start the command - startGroup := &errgroup.Group{} - startGroup.Go(func() error { - gc.result = icmd.StartCmd(iCmdCmd) - if gc.result.Error != nil { - gc.t.Log("start command failed") - gc.t.Log(gc.result.ExitCode) - gc.t.Log(gc.result.Error) - - return gc.result.Error - } - - for _, writer := range gc.ptyWriters { - err := writer(psty) - if err != nil { - gc.t.Log("writing to the pty failed") - gc.t.Log(err) - - return err - } - } - - return nil - }) - - // Let the error through for WaitOnCmd to handle - _ = startGroup.Wait() - } else { - // Run it - gc.result = icmd.StartCmd(iCmdCmd) - } - } + _ = gc.cmd.Run(context.WithValue(context.Background(), com.LoggerKey, gc.t)) +} - result = icmd.WaitOnCmd(gc.timeout, gc.result) - env = gc.result.Cmd.Env +func (gc *GenericCommand) Signal(sig os.Signal) error { + //nolint:wrapcheck + return gc.cmd.Signal(sig) +} - if gc.pty { - _ = tty.Close() - _ = psty.Close() - _ = copyGroup.Wait() +func (gc *GenericCommand) Run(expect *Expected) { + if gc.t != nil { + gc.t.Helper() } - stdout = result.Stdout() - if stdout == "" { - stdout = output.String() + if !gc.async { + _ = gc.cmd.Run(context.WithValue(context.Background(), com.LoggerKey, gc.t)) } - gc.rawStdErr = result.Stderr() + result, err := gc.cmd.Wait() + if result != nil { + 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") + // Build the debug string + separator := "=================================" + debugCommand := gc.cmd.Binary + " " + strings.Join(gc.cmd.Args, " ") + debugTimeout := gc.cmd.Timeout + debugWD := gc.cmd.WorkingDir + + // FIXME: this is ugly af. Do better. + debug := fmt.Sprintf( + "\n%s\n| Command:\t%s\n| Working Dir:\t%s\n| Timeout:\t%s\n%s\n"+ + "%s\n%s\n| Stderr:\n%s\n%s\n%s\n| Stdout:\n%s\n%s\n%s\n| Exit Code: %d\n| Signaled: %v\n| Err: %v\n%s", + separator, + debugCommand, + debugWD, + debugTimeout, + separator, + "\t"+strings.Join(result.Environ, "\n\t"), + separator, + separator, + result.Stderr, + separator, + separator, + result.Stdout, + separator, + result.ExitCode, + result.Signal, + err, + separator, + ) // ExitCode goes first switch expect.ExitCode { case internal.ExitCodeNoCheck: - // ExitCodeNoCheck means we do not care at all about exit code. It can be a failure, a - // success, or a timeout. + // ExitCodeNoCheck means we do not care at all about what happened. Fire and forget... case internal.ExitCodeGenericFail: - // ExitCodeGenericFail means we expect an error (excluding timeout). - assertive.True(gc.t, result.ExitCode != 0, - "Expected exit code to be different than 0\n"+debug) + // ExitCodeGenericFail means we expect an error (excluding timeout, cancellation, + // signalling). + assertive.ErrorIs( + gc.t, + err, + com.ErrExecutionFailed, + "Command should have failed", + debug, + ) case internal.ExitCodeTimeout: - assertive.True(gc.t, expect.ExitCode == internal.ExitCodeTimeout, - "Command unexpectedly timed-out\n"+debug) + assertive.ErrorIs( + gc.t, + err, + com.ErrTimeout, + "Command should have timed out", + debug, + ) + case internal.ExitCodeSignaled: + assertive.ErrorIs( + gc.t, + err, + com.ErrSignaled, + "Command should have been signaled", + debug, + ) + case internal.ExitCodeSuccess: + assertive.ErrorIsNil(gc.t, err, "Command should have succeeded", debug) default: - assertive.True(gc.t, expect.ExitCode == result.ExitCode, - fmt.Sprintf("Expected exit code: %d\n", expect.ExitCode)+debug) + assertive.IsEqual(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 { - assertive.True(gc.t, strings.Contains(gc.rawStdErr, expectErr.Error()), - fmt.Sprintf("Expected error: %q to be found in stderr\n", expectErr.Error())+debug) + assertive.StringContains(gc.t, result.Stderr, 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(stdout, debug, gc.t) + expect.Output(result.Stdout, debug, gc.t) } } } @@ -242,27 +249,9 @@ 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) Signal(sig os.Signal) error { - return gc.result.Cmd.Process.Signal(sig) //nolint:wrapcheck -} - 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 + gc.cmd.Env[k] = v } } @@ -270,33 +259,28 @@ 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...) -} - //nolint:ireturn func (gc *GenericCommand) Clone() TestableCommand { // Copy the command and return a new one - with almost everything from the parent command - com := *gc - com.result = nil - com.stdin = nil - com.timeout = 0 - com.rawStdErr = "" + clone := *gc + clone.rawStdErr = "" + clone.async = false + // Clone Env - com.Env = make(map[string]string, len(gc.Env)) + clone.Env = make(map[string]string, len(gc.Env)) for k, v := range gc.Env { - com.Env[k] = v + clone.Env[k] = v } - return &com + // Clone the underlying command + clone.cmd = gc.cmd.Clone() + clone.cmd.Env = clone.Env + + return &clone } func (gc *GenericCommand) T() *testing.T { @@ -305,21 +289,23 @@ func (gc *GenericCommand) T() *testing.T { //nolint:ireturn func (gc *GenericCommand) clear() TestableCommand { - com := *gc - com.mainBinary = "" - com.helperBinary = "" - com.mainArgs = []string{} - com.prependArgs = []string{} - com.helperArgs = []string{} + comcopy := *gc + // Reset internal command + comcopy.cmd = &com.Command{} + comcopy.rawStdErr = "" + comcopy.async = false // Clone Env - com.Env = make(map[string]string, len(gc.Env)) + comcopy.Env = make(map[string]string, len(gc.Env)) // Reset configuration - com.Config = &config{} + comcopy.Config = &config{} + // Copy the env for k, v := range gc.Env { - com.Env[k] = v + comcopy.Env[k] = v } - return &com + comcopy.cmd.Env = comcopy.Env + + return &comcopy } func (gc *GenericCommand) withT(t *testing.T) { @@ -334,58 +320,3 @@ func (gc *GenericCommand) read(key ConfigKey) ConfigValue { 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 - //nolint:gocritic - 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 _, envValue := range os.Environ() { - add := true - - for _, b := range gc.envBlackList { - if strings.HasPrefix(envValue, b+"=") { - add = false - - break - } - } - - if add { - iCmdCmd.Env = append(iCmdCmd.Env, envValue) - } - } - - // 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/mod/tigron/test/helpers.go b/mod/tigron/test/helpers.go index 80725bd613d..3cc6cae6317 100644 --- a/mod/tigron/test/helpers.go +++ b/mod/tigron/test/helpers.go @@ -19,7 +19,7 @@ package test import ( "testing" - "github.com/containerd/nerdctl/mod/tigron/test/internal" + "github.com/containerd/nerdctl/mod/tigron/internal" ) // This is the implementation of Helpers @@ -75,7 +75,7 @@ func (help *helpersInternal) Err(args ...string) string { // Command will return a clone of your base command without running it. // -//nolint:ireturn +//nolint:ireturn,nolintlint func (help *helpersInternal) Command(args ...string) TestableCommand { cc := help.cmdInternal.Clone() cc.WithArgs(args...) @@ -86,7 +86,7 @@ func (help *helpersInternal) Command(args ...string) TestableCommand { // Custom will return a command for the requested binary and args, with the environment of your test // (eg: Env, Cwd, etc.) // -//nolint:ireturn +//nolint:ireturn,nolintlint func (help *helpersInternal) Custom(binary string, args ...string) TestableCommand { cc := help.cmdInternal.clear() cc.WithBinary(binary) diff --git a/mod/tigron/test/interfaces.go b/mod/tigron/test/interfaces.go index ef7533e85a0..8f8727a8298 100644 --- a/mod/tigron/test/interfaces.go +++ b/mod/tigron/test/interfaces.go @@ -83,24 +83,29 @@ type TestableCommand interface { //nolint:interfacebloat WithArgs(args ...string) // WithWrapper allows wrapping a command with another command (for example: `time`). WithWrapper(binary string, args ...string) - WithPseudoTTY(writers ...func(*os.File) error) - // WithStdin allows passing a reader to be used for stdin for the command. - WithStdin(r io.Reader) + // WithPseudoTTY will allocate a new pty and set the command stdin and stdout to it. + WithPseudoTTY() // WithCwd allows specifying the working directory for the command. WithCwd(path string) + // WithTimeout defines the execution timeout for a command. + WithTimeout(timeout time.Duration) + // WithFeeder allows passing a reader to be fed to the command stdin. + WithFeeder(fun func() io.Reader) + // Feed allows passing a reader to be fed to the command stdin. + Feed(r io.Reader) // 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 + // is verified to be successful. Run(expect *Expected) - // Background allows starting a command in the background - Background(timeout time.Duration) - // Signal sends a signal to a backgrounded command + // Background allows starting a command in the background. + Background() + // Signal sends a signal to a backgrounded command. Signal(sig os.Signal) error - // Stderr allows retrieving the raw stderr output of the command once it has been run + // Stderr allows retrieving the raw stderr output of the command once it has been run. Stderr() string } diff --git a/mod/tigron/test/package_test.go b/mod/tigron/test/package_test.go new file mode 100644 index 00000000000..d50c6814b2d --- /dev/null +++ b/mod/tigron/test/package_test.go @@ -0,0 +1,70 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package test_test + +import ( + "fmt" + "os" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/internal/highk" +) + +func TestMain(m *testing.M) { + // Prep exit code + exitCode := 0 + defer func() { os.Exit(exitCode) }() + + var ( + snapFile *os.File + before, after []byte + ) + + if os.Getenv("HIGHK_EXPERIMENTAL_FD") != "" { + snapFile, _ = os.CreateTemp("", "fileleaks") + before, _ = highk.SnapshotOpenFiles(snapFile) + } + + exitCode = m.Run() + + if exitCode != 0 { + return + } + + if os.Getenv("HIGHK_EXPERIMENTAL_FD") != "" { + after, _ = highk.SnapshotOpenFiles(snapFile) + diff := highk.Diff(string(before), string(after)) + + if len(diff) != 0 { + _, _ = fmt.Fprintln(os.Stderr, "Leaking file descriptors") + + for _, file := range diff { + _, _ = fmt.Fprintln(os.Stderr, file) + } + + exitCode = 1 + } + } + + if err := highk.FindGoRoutines(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Leaking go routines") + + _, _ = fmt.Fprintln(os.Stderr, os.Stderr, err.Error()) + + exitCode = 1 + } +} diff --git a/pkg/cmd/container/attach.go b/pkg/cmd/container/attach.go index 177a9fd03db..31a1523e9e9 100644 --- a/pkg/cmd/container/attach.go +++ b/pkg/cmd/container/attach.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" + "golang.org/x/term" + "github.com/containerd/console" containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/pkg/cio" @@ -93,7 +95,8 @@ func Attach(ctx context.Context, client *containerd.Client, req string, options return err } defer con.Reset() - if err := con.SetRaw(); err != nil { + + if _, err := term.MakeRaw(int(con.Fd())); err != nil { return fmt.Errorf("failed to set the console to raw mode: %w", err) } closer := func() { diff --git a/pkg/cmd/container/exec.go b/pkg/cmd/container/exec.go index c00c776998b..0c087e63782 100644 --- a/pkg/cmd/container/exec.go +++ b/pkg/cmd/container/exec.go @@ -23,6 +23,7 @@ import ( "os" "github.com/opencontainers/runtime-spec/specs-go" + "golang.org/x/term" "github.com/containerd/console" containerd "github.com/containerd/containerd/v2/client" @@ -111,7 +112,7 @@ func execActionWithContainer(ctx context.Context, client *containerd.Client, con return err } defer con.Reset() - if err := con.SetRaw(); err != nil { + if _, err := term.MakeRaw(int(con.Fd())); err != nil { return err } } diff --git a/pkg/containerutil/containerutil.go b/pkg/containerutil/containerutil.go index 1e4fe2a34e3..ee71d5853fa 100644 --- a/pkg/containerutil/containerutil.go +++ b/pkg/containerutil/containerutil.go @@ -33,6 +33,7 @@ import ( dockeropts "github.com/docker/docker/opts" "github.com/moby/sys/signal" "github.com/opencontainers/runtime-spec/specs-go" + "golang.org/x/term" "github.com/containerd/console" containerd "github.com/containerd/containerd/v2/client" @@ -248,7 +249,7 @@ func Start(ctx context.Context, container containerd.Container, flagA bool, clie return err } defer con.Reset() - if err := con.SetRaw(); err != nil { + if _, err := term.MakeRaw(int(con.Fd())); err != nil { return err } } diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 4966ad21254..9ebe907355e 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -30,13 +30,14 @@ import ( "sync" "time" - containerd "github.com/containerd/containerd/v2/client" "github.com/fsnotify/fsnotify" "github.com/muesli/cancelreader" + containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/runtime/v2/logging" "github.com/containerd/errdefs" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/lockutil" ) diff --git a/pkg/testutil/nerdtest/command.go b/pkg/testutil/nerdtest/command.go index 2675d07a78c..b979963d662 100644 --- a/pkg/testutil/nerdtest/command.go +++ b/pkg/testutil/nerdtest/command.go @@ -21,7 +21,6 @@ import ( "os/exec" "path/filepath" "testing" - "time" "gotest.tools/v3/assert" @@ -78,7 +77,10 @@ func newNerdCommand(conf test.Config, t *testing.T) *nerdCommand { } // Create the base command, with the right binary, t - ret := &nerdCommand{} + ret := &nerdCommand{ + GenericCommand: *(test.NewGenericCommand().(*test.GenericCommand)), + } + ret.WithBinary(binary) // Not interested in these - and insulate us from parent environment side effects ret.WithBlacklist([]string{ @@ -113,9 +115,9 @@ func (nc *nerdCommand) Run(expect *test.Expected) { nc.GenericCommand.Run(expect) } -func (nc *nerdCommand) Background(timeout time.Duration) { +func (nc *nerdCommand) Background() { nc.prep() - nc.GenericCommand.Background(timeout) + nc.GenericCommand.Background() } // Run does override the generic command run, as we are testing both docker and nerdctl diff --git a/pkg/testutil/nerdtest/utilities_linux.go b/pkg/testutil/nerdtest/utilities_linux.go index 2a12bfd1aec..75c199acd25 100644 --- a/pkg/testutil/nerdtest/utilities_linux.go +++ b/pkg/testutil/nerdtest/utilities_linux.go @@ -47,7 +47,8 @@ func RunSigProxyContainer(signal os.Signal, exitOnSignal bool, args []string, da trap sig_msg ` + sig + ` printf "` + ready + `\n" while true; do - sleep 0.1 + printf "waiting...\n" + sleep 0.5 done ` @@ -55,7 +56,9 @@ func RunSigProxyContainer(signal os.Signal, exitOnSignal bool, args []string, da args = append([]string{"run"}, args...) cmd := helpers.Command(args...) - cmd.Background(10 * time.Second) + // NOTE: because of a test like TestStopWithStopSignal, we need to wait enough for nerdctl to terminate the container + cmd.WithTimeout(20 * time.Second) + cmd.Background() EnsureContainerStarted(helpers, data.Identifier()) for {