Skip to content

Commit c07d2c2

Browse files
authored
fix: pass through signals to inner container (#83)
1 parent 07fae1e commit c07d2c2

File tree

10 files changed

+247
-43
lines changed

10 files changed

+247
-43
lines changed

cli/clitest/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func New(t *testing.T, cmd string, args ...string) (context.Context, *cobra.Comm
5656
ctx = ctx(t, fs, execer, mnt, client)
5757
)
5858

59-
root := cli.Root()
59+
root := cli.Root(nil)
6060
// This is the one thing that isn't really mocked for the tests.
6161
// I cringe at the thought of introducing yet another mock so
6262
// let's avoid it for now.

cli/docker.go

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/url"
99
"os"
10+
"os/exec"
1011
"path"
1112
"path/filepath"
1213
"sort"
@@ -145,7 +146,7 @@ type flags struct {
145146
ethlink string
146147
}
147148

148-
func dockerCmd() *cobra.Command {
149+
func dockerCmd(ch chan func() error) *cobra.Command {
149150
var flags flags
150151

151152
cmd := &cobra.Command{
@@ -284,7 +285,7 @@ func dockerCmd() *cobra.Command {
284285
return xerrors.Errorf("wait for dockerd: %w", err)
285286
}
286287

287-
err = runDockerCVM(ctx, log, client, blog, flags)
288+
err = runDockerCVM(ctx, log, client, blog, ch, flags)
288289
if err != nil {
289290
// It's possible we failed because we ran out of disk while
290291
// pulling the image. We should restart the daemon and use
@@ -313,7 +314,7 @@ func dockerCmd() *cobra.Command {
313314
}()
314315

315316
log.Debug(ctx, "reattempting container creation")
316-
err = runDockerCVM(ctx, log, client, blog, flags)
317+
err = runDockerCVM(ctx, log, client, blog, ch, flags)
317318
}
318319
if err != nil {
319320
blog.Errorf("Failed to run envbox: %v", err)
@@ -356,7 +357,7 @@ func dockerCmd() *cobra.Command {
356357
return cmd
357358
}
358359

359-
func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.DockerClient, blog buildlog.Logger, flags flags) error {
360+
func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.DockerClient, blog buildlog.Logger, shutdownCh chan func() error, flags flags) error {
360361
fs := xunix.GetFS(ctx)
361362

362363
// Set our OOM score to something really unfavorable to avoid getting killed
@@ -676,31 +677,71 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Docker
676677
}
677678

678679
blog.Info("Envbox startup complete!")
679-
680-
// The bootstrap script doesn't return since it execs the agent
681-
// meaning that it can get pretty noisy if we were to log by default.
682-
// In order to allow users to discern issues getting the bootstrap script
683-
// to complete successfully we pipe the output to stdout if
684-
// CODER_DEBUG=true.
685-
debugWriter := io.Discard
686-
if flags.debug {
687-
debugWriter = os.Stdout
688-
}
689-
// Bootstrap the container if a script has been provided.
690-
blog.Infof("Bootstrapping workspace...")
691-
err = dockerutil.BootstrapContainer(ctx, client, dockerutil.BootstrapConfig{
692-
ContainerID: containerID,
693-
User: imgMeta.UID,
694-
Script: flags.boostrapScript,
695-
// We set this because the default behavior is to download the agent
696-
// to /tmp/coder.XXXX. This causes a race to happen where we finish
697-
// downloading the binary but before we can execute systemd remounts
698-
// /tmp.
699-
Env: []string{fmt.Sprintf("BINARY_DIR=%s", bootDir)},
700-
StdOutErr: debugWriter,
680+
if flags.boostrapScript == "" {
681+
return nil
682+
}
683+
684+
bootstrapExec, err := client.ContainerExecCreate(ctx, containerID, dockertypes.ExecConfig{
685+
User: imgMeta.UID,
686+
Cmd: []string{"/bin/sh", "-s"},
687+
Env: []string{fmt.Sprintf("BINARY_DIR=%s", bootDir)},
688+
AttachStdin: true,
689+
AttachStdout: true,
690+
AttachStderr: true,
691+
Detach: true,
701692
})
702693
if err != nil {
703-
return xerrors.Errorf("boostrap container: %w", err)
694+
return xerrors.Errorf("create exec: %w", err)
695+
}
696+
697+
resp, err := client.ContainerExecAttach(ctx, bootstrapExec.ID, dockertypes.ExecStartCheck{})
698+
if err != nil {
699+
return xerrors.Errorf("attach exec: %w", err)
700+
}
701+
702+
_, err = io.Copy(resp.Conn, strings.NewReader(flags.boostrapScript))
703+
if err != nil {
704+
return xerrors.Errorf("copy stdin: %w", err)
705+
}
706+
err = resp.CloseWrite()
707+
if err != nil {
708+
return xerrors.Errorf("close write: %w", err)
709+
}
710+
711+
go func() {
712+
defer resp.Close()
713+
rd := io.LimitReader(resp.Reader, 1<<10)
714+
_, err := io.Copy(blog, rd)
715+
if err != nil {
716+
log.Error(ctx, "copy bootstrap output", slog.Error(err))
717+
}
718+
}()
719+
720+
// We can't just call ExecInspect because there's a race where the cmd
721+
// hasn't been assigned a PID yet.
722+
bootstrapPID, err := dockerutil.GetExecPID(ctx, client, bootstrapExec.ID)
723+
if err != nil {
724+
return xerrors.Errorf("exec inspect: %w", err)
725+
}
726+
727+
shutdownCh <- func() error {
728+
log.Debug(ctx, "killing container", slog.F("bootstrap_pid", bootstrapPID))
729+
730+
// The PID returned is the PID _outside_ the container...
731+
//nolint:gosec
732+
out, err := exec.Command("kill", "-TERM", strconv.Itoa(bootstrapPID)).CombinedOutput()
733+
if err != nil {
734+
return xerrors.Errorf("kill bootstrap process (%s): %w", out, err)
735+
}
736+
737+
log.Debug(ctx, "sent kill signal waiting for process to exit")
738+
err = dockerutil.WaitForExit(ctx, client, bootstrapExec.ID)
739+
if err != nil {
740+
return xerrors.Errorf("wait for exit: %w", err)
741+
}
742+
743+
log.Debug(ctx, "bootstrap process successfully exited")
744+
return nil
704745
}
705746

706747
return nil

cli/root.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import (
44
"github.com/spf13/cobra"
55
)
66

7-
func Root() *cobra.Command {
7+
func Root(ch chan func() error) *cobra.Command {
88
cmd := &cobra.Command{
99
Use: "envbox",
1010
SilenceErrors: true,
@@ -15,6 +15,6 @@ func Root() *cobra.Command {
1515
},
1616
}
1717

18-
cmd.AddCommand(dockerCmd())
18+
cmd.AddCommand(dockerCmd(ch))
1919
return cmd
2020
}

cmd/envbox/main.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,44 @@
11
package main
22

33
import (
4-
"fmt"
4+
"context"
55
"os"
6+
"os/signal"
67
"runtime"
8+
"syscall"
79

10+
"cdr.dev/slog"
11+
"cdr.dev/slog/sloggers/slogjson"
812
"github.com/coder/envbox/cli"
913
)
1014

1115
func main() {
12-
_, err := cli.Root().ExecuteC()
16+
ch := make(chan func() error, 1)
17+
sigs := make(chan os.Signal, 1)
18+
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT, syscall.SIGWINCH)
19+
go func() {
20+
log := slog.Make(slogjson.Sink(os.Stderr))
21+
ctx := context.Background()
22+
log.Info(ctx, "waiting for signal")
23+
<-sigs
24+
log.Info(ctx, "got signal")
25+
select {
26+
case fn := <-ch:
27+
log.Info(ctx, "running shutdown function")
28+
err := fn()
29+
if err != nil {
30+
log.Error(ctx, "shutdown function failed", slog.Error(err))
31+
os.Exit(1)
32+
}
33+
default:
34+
log.Info(ctx, "no shutdown function")
35+
}
36+
log.Info(ctx, "exiting")
37+
os.Exit(0)
38+
}()
39+
_, err := cli.Root(ch).ExecuteC()
1340
if err != nil {
14-
_, _ = fmt.Fprintln(os.Stderr, err.Error())
1541
os.Exit(1)
1642
}
17-
18-
// We exit the main thread while keepin all the other procs goin strong.
1943
runtime.Goexit()
2044
}

dockerutil/container.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func BootstrapContainer(ctx context.Context, client DockerClient, conf Bootstrap
113113

114114
var err error
115115
for r, n := retry.New(time.Second, time.Second*2), 0; r.Wait(ctx) && n < 10; n++ {
116-
var out []byte
116+
var out io.Reader
117117
out, err = ExecContainer(ctx, client, ExecConfig{
118118
ContainerID: conf.ContainerID,
119119
User: conf.User,
@@ -122,9 +122,16 @@ func BootstrapContainer(ctx context.Context, client DockerClient, conf Bootstrap
122122
Stdin: strings.NewReader(conf.Script),
123123
Env: conf.Env,
124124
StdOutErr: conf.StdOutErr,
125+
Detach: conf.Detach,
125126
})
126127
if err != nil {
127-
err = xerrors.Errorf("boostrap container (%s): %w", out, err)
128+
output, rerr := io.ReadAll(out)
129+
if rerr != nil {
130+
err = xerrors.Errorf("read all: %w", err)
131+
continue
132+
}
133+
134+
err = xerrors.Errorf("boostrap container (%s): %w", output, err)
128135
continue
129136
}
130137
break

dockerutil/dockerfake/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ func (m MockClient) ContainerExecCreate(ctx context.Context, name string, config
162162

163163
func (m MockClient) ContainerExecInspect(ctx context.Context, id string) (dockertypes.ContainerExecInspect, error) {
164164
if m.ContainerExecInspectFn == nil {
165-
return dockertypes.ContainerExecInspect{}, nil
165+
return dockertypes.ContainerExecInspect{
166+
Pid: 1,
167+
}, nil
166168
}
167169

168170
return m.ContainerExecInspectFn(ctx, id)

dockerutil/exec.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"bytes"
55
"context"
66
"io"
7+
"time"
78

89
dockertypes "github.com/docker/docker/api/types"
910
"golang.org/x/xerrors"
1011

1112
"github.com/coder/envbox/xio"
13+
"github.com/coder/retry"
1214
)
1315

1416
type ExecConfig struct {
@@ -24,9 +26,9 @@ type ExecConfig struct {
2426

2527
// ExecContainer runs a command in a container. It returns the output and any error.
2628
// If an error occurs during the execution of the command, the output is appended to the error.
27-
func ExecContainer(ctx context.Context, client DockerClient, config ExecConfig) ([]byte, error) {
29+
func ExecContainer(ctx context.Context, client DockerClient, config ExecConfig) (io.Reader, error) {
2830
exec, err := client.ContainerExecCreate(ctx, config.ContainerID, dockertypes.ExecConfig{
29-
Detach: true,
31+
Detach: config.Detach,
3032
Cmd: append([]string{config.Cmd}, config.Args...),
3133
User: config.User,
3234
AttachStderr: true,
@@ -90,5 +92,41 @@ func ExecContainer(ctx context.Context, client DockerClient, config ExecConfig)
9092
return nil, xerrors.Errorf("%s: exit code %d", buf.Bytes(), inspect.ExitCode)
9193
}
9294

93-
return buf.Bytes(), nil
95+
return &buf, nil
96+
}
97+
98+
func GetExecPID(ctx context.Context, client DockerClient, execID string) (int, error) {
99+
for r := retry.New(time.Second, time.Second); r.Wait(ctx); {
100+
inspect, err := client.ContainerExecInspect(ctx, execID)
101+
if err != nil {
102+
return 0, xerrors.Errorf("exec inspect: %w", err)
103+
}
104+
105+
if inspect.Pid == 0 {
106+
continue
107+
}
108+
return inspect.Pid, nil
109+
}
110+
111+
return 0, ctx.Err()
112+
}
113+
114+
func WaitForExit(ctx context.Context, client DockerClient, execID string) error {
115+
for r := retry.New(time.Second, time.Second); r.Wait(ctx); {
116+
inspect, err := client.ContainerExecInspect(ctx, execID)
117+
if err != nil {
118+
return xerrors.Errorf("exec inspect: %w", err)
119+
}
120+
121+
if inspect.Running {
122+
continue
123+
}
124+
125+
if inspect.ExitCode > 0 {
126+
return xerrors.Errorf("exit code %d", inspect.ExitCode)
127+
}
128+
129+
return nil
130+
}
131+
return ctx.Err()
94132
}

dockerutil/image.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package dockerutil
22

33
import (
4-
"bytes"
54
"context"
65
"encoding/json"
76
"fmt"
@@ -208,7 +207,7 @@ func GetImageMetadata(ctx context.Context, client DockerClient, image, username
208207
return ImageMetadata{}, xerrors.Errorf("get /etc/passwd entry for %s: %w", username, err)
209208
}
210209

211-
users, err := xunix.ParsePasswd(bytes.NewReader(out))
210+
users, err := xunix.ParsePasswd(out)
212211
if err != nil {
213212
return ImageMetadata{}, xerrors.Errorf("parse passwd entry for (%s): %w", out, err)
214213
}

0 commit comments

Comments
 (0)