Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/nerdctl/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func Command() *cobra.Command {
createCommand(),
waitCommand(),
scaleCommand(),
attachCommand(),
)

return cmd
Expand Down
88 changes: 88 additions & 0 deletions cmd/nerdctl/compose/compose_attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright The containerd Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package compose

import (
"errors"

"github.com/spf13/cobra"

"github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers"
"github.com/containerd/nerdctl/v2/pkg/clientutil"
"github.com/containerd/nerdctl/v2/pkg/cmd/compose"
"github.com/containerd/nerdctl/v2/pkg/composer"
"github.com/containerd/nerdctl/v2/pkg/consoleutil"
)

func attachCommand() *cobra.Command {
var cmd = &cobra.Command{
Use: "attach [flags] SERVICE",
Short: "Attach local standard input, output, and error streams to a service's running container",
Args: cobra.ExactArgs(1),
RunE: attachAction,
SilenceUsage: true,
SilenceErrors: true,
}
cmd.Flags().Int("index", 1, "index of the container if the service has multiple instances.")
cmd.Flags().String("detach-keys", consoleutil.DefaultDetachKeys, "Override the default detach keys")
cmd.Flags().Bool("no-stdin", false, "Do not attach STDIN")
return cmd
}

func attachAction(cmd *cobra.Command, args []string) error {
globalOptions, err := helpers.ProcessRootCmdFlags(cmd)
if err != nil {
return err
}
index, err := cmd.Flags().GetInt("index")
if err != nil {
return err
}
if index < 1 {
return errors.New("index starts from 1 and should be equal or greater than 1")
}
detachKeys, err := cmd.Flags().GetString("detach-keys")
if err != nil {
return err
}
noStdin, err := cmd.Flags().GetBool("no-stdin")
if err != nil {
return err
}

client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address)
if err != nil {
return err
}
defer cancel()
options, err := getComposeOptions(cmd, globalOptions.DebugFull, globalOptions.Experimental)
if err != nil {
return err
}
c, err := compose.New(client, globalOptions, options, cmd.OutOrStdout(), cmd.ErrOrStderr())
if err != nil {
return err
}

ao := composer.AttachOptions{
ServiceName: args[0],
Index: index,
DetachKeys: detachKeys,
NoStdin: noStdin,
}
return c.Attach(ctx, ao)
}
154 changes: 154 additions & 0 deletions cmd/nerdctl/compose/compose_attach_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
Copyright The containerd Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package compose

import (
"bytes"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"testing"
"time"

"gotest.tools/v3/assert"

"github.com/containerd/nerdctl/mod/tigron/expect"
"github.com/containerd/nerdctl/mod/tigron/test"
"github.com/containerd/nerdctl/mod/tigron/tig"

"github.com/containerd/nerdctl/v2/pkg/composer/serviceparser"
"github.com/containerd/nerdctl/v2/pkg/testutil"
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
)

func TestComposeAttach(t *testing.T) {
testCase := nerdtest.Setup()

testCase.Setup = func(data test.Data, helpers test.Helpers) {
// Like nerdctl attach, compose attach needs a container created with stdin open and a TTY.
dockerComposeYAML := fmt.Sprintf(`
services:
svc0:
image: %s
stdin_open: true
tty: true
`, testutil.CommonImage)

composePath := data.Temp().Save(dockerComposeYAML, "compose.yaml")
projectName := filepath.Base(filepath.Dir(composePath))
containerName := serviceparser.DefaultContainerName(projectName, "svc0", "1")
data.Labels().Set("composeYAML", composePath)
data.Labels().Set("containerName", containerName)

// compose up runs a service with stdin_open and tty like nerdctl run -it, in the foreground:
// give it a console and detach from it, like TestAttach does with nerdctl run -it.
cmd := helpers.Command("compose", "-f", composePath, "up", "-d")
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: expect.ExitCodeSuccess,
Errors: []error{errors.New("read detach keys")},
})
nerdtest.EnsureContainerStarted(helpers, containerName)
}

testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
if data.Labels().Get("composeYAML") != "" {
helpers.Anyhow("compose", "-f", data.Labels().Get("composeYAML"), "down", "-v")
}
}

testCase.SubTests = []*test.Case{
{
Description: "rejects an index out of range",
NoParallel: true,
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "attach", "--index", "2", "svc0")
},
Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil),
},
{
Description: "attaches to the service container",
NoParallel: true,
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
cmd := helpers.Command("compose", "-f", data.Labels().Get("composeYAML"), "attach", "svc0")
cmd.WithPseudoTTY()
// compose attach loads the project and runs nerdctl attach in a subprocess first, which can
// take several seconds on slow machines, and input that is pending together with the detach
// keys when nerdctl attach starts can be lost (see TestAttach). So send the command once a
// second for a while, then detach: whenever attach is up, a later command reaches the container.
cmd.WithFeeder(func() io.Reader {
reader, writer := io.Pipe()
go func() {
for range 10 {
if _, err := writer.Write([]byte("echo mark${NON}mark | tee -a /tmp/attached\n")); err != nil {
return
}
time.Sleep(time.Second)
}
// ctrl+p and ctrl+q (see https://en.wikipedia.org/wiki/C0_and_C1_control_codes)
_, _ = writer.Write([]byte{16, 17})
_ = writer.Close()
}()
return reader
})
return cmd
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
// Detaching from attach exits with status 1, like docker.
// https://github.com/containerd/nerdctl/issues/3571
ExitCode: 1,
Errors: []error{errors.New("read detach keys")},
// The shell's output normally comes back through attach, but the pseudo-TTY output can be empty
// when attach exits on the detach keys. The command also writes its output to a file, so check
// that file in the container in that case.
Output: func(stdout string, t tig.T) {
if strings.Contains(stdout, "markmark") {
return
}
container := data.Labels().Get("containerName")
var written string
logOutput := func(out *string) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeNoCheck,
Output: func(stdout string, t tig.T) { *out = stdout },
}
}
helpers.Command("exec", container, "cat", "/tmp/attached").Run(logOutput(&written))
if !strings.Contains(written, "markmark") {
// TODO: temporary, to find out why the input is lost on some CI runs.
var procs, state string
helpers.Command("exec", container, "sh", "-c",
"ps -o pid,stat,args; ls -l /proc/1/fd; grep -E 'State|SigBlk|SigIgn' /proc/1/status").
Run(logOutput(&procs))
helpers.Command("inspect", "--format", "{{json .State}}", container).Run(logOutput(&state))
t.Log("container processes:\n" + procs + "\nstate: " + state)
}
assert.Assert(t, strings.Contains(written, "markmark"),
"the command sent through attach did not run in the container")
},
}
},
},
}

testCase.Run(t)
}
18 changes: 17 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
- [:whale: nerdctl compose logs](#whale-nerdctl-compose-logs)
- [:whale: nerdctl compose build](#whale-nerdctl-compose-build)
- [:whale: nerdctl compose create](#whale-nerdctl-compose-create)
- [:whale: nerdctl compose attach](#whale-nerdctl-compose-attach)
- [:whale: nerdctl compose exec](#whale-nerdctl-compose-exec)
- [:whale: nerdctl compose down](#whale-nerdctl-compose-down)
- [:whale: nerdctl compose images](#whale-nerdctl-compose-images)
Expand Down Expand Up @@ -1792,6 +1793,22 @@ Flags:
- :whale: `--no-recreate`: Don't recreate containers if they exist, conflict with `--force-recreate`
- :whale: `--pull`: Pull images before running. (support always|missing|never) (default "missing")

### :whale: nerdctl compose attach

Attach local standard input, output, and error streams to a service's running container

Like `nerdctl attach`, it needs a container created with `stdin_open: true` and `tty: true`.
`nerdctl compose up` runs such a service in the foreground, like `nerdctl run -it`; detach from it
with the detach keys (`ctrl-p ctrl-q` by default) to leave it running for `nerdctl compose attach`.

Usage: `nerdctl compose attach [OPTIONS] SERVICE`

Flags:

- :whale: `--index`: index of the container if the service has multiple instances. (default 1)
- :whale: `--detach-keys`: Override the default detach keys (default "ctrl-p,ctrl-q")
- :whale: `--no-stdin`: Do not attach STDIN

### :whale: nerdctl compose exec

Execute a command on a running container of the service.
Expand Down Expand Up @@ -2106,7 +2123,6 @@ Network management:

Compose:

- `docker compose attach`
- `docker compose events`
- `docker compose ls`
- `docker compose stats`
Expand Down
69 changes: 69 additions & 0 deletions pkg/composer/attach.go
Original file line number Diff line number Diff line change
@@ -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 composer

import (
"context"
"fmt"
"os"

"github.com/containerd/log"
)

// AttachOptions stores options passed from users as flags and args.
type AttachOptions struct {
ServiceName string
Index int
// params to be passed to `nerdctl attach`
DetachKeys string
NoStdin bool
}

// Attach attaches the standard input, output and error streams of the
// container specified by `ServiceName` (and `Index` if it has multiple
// instances) to the terminal.
func (c *Composer) Attach(ctx context.Context, ao AttachOptions) error {
// Attach does not need to lock and should allow concurrency.
if err := Unlock(); err != nil {
return err
}

container, err := c.serviceContainer(ctx, ao.ServiceName, ao.Index)
if err != nil {
return err
}

args := []string{
"attach",
fmt.Sprintf("--no-stdin=%t", ao.NoStdin),
}
if ao.DetachKeys != "" {
args = append(args, "--detach-keys", ao.DetachKeys)
}
args = append(args, container.ID())
cmd := c.createNerdctlCmd(ctx, args...)
if !ao.NoStdin {
cmd.Stdin = os.Stdin
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if c.DebugPrintFull {
log.G(ctx).Debugf("Executing %v", cmd.Args)
}
return cmd.Run()
}
Loading
Loading