diff --git a/hat-examples/on_start/Dockerfile b/hat-examples/on_start/Dockerfile new file mode 100644 index 0000000..636435e --- /dev/null +++ b/hat-examples/on_start/Dockerfile @@ -0,0 +1,6 @@ +FROM codercom/ubuntu-dev + +# The command in the on_start label will be run immediately after the +# project starts. You could use this to reinstall dependencies or +# perform any other bootstrapping tasks. +LABEL on_start="touch did_on_start" diff --git a/internal/dockutil/exec.go b/internal/dockutil/exec.go index a16ca58..0e09de5 100644 --- a/internal/dockutil/exec.go +++ b/internal/dockutil/exec.go @@ -11,6 +11,11 @@ func Exec(cntName, cmd string, args ...string) *exec.Cmd { return exec.Command("docker", args...) } +func ExecDir(cntName, dir, cmd string, args ...string) *exec.Cmd { + args = append([]string{"exec", "-w", dir, "-i", cntName, cmd}, args...) + return exec.Command("docker", args...) +} + func ExecTTY(cntName, dir, cmd string, args ...string) *exec.Cmd { args = append([]string{"exec", "-w", dir, "-it", cntName, cmd}, args...) return exec.Command("docker", args...) @@ -25,6 +30,11 @@ func DetachedExec(cntName, cmd string, args ...string) *exec.Cmd { return exec.Command("docker", args...) } +func DetachedExecDir(cntName, dir, cmd string, args ...string) *exec.Cmd { + args = append([]string{"exec", "-dw", dir, cntName, cmd}, args...) + return exec.Command("docker", args...) +} + func ExecEnv(cntName string, envs []string, cmd string, args ...string) *exec.Cmd { args = append([]string{"exec", "-e", strings.Join(envs, ","), "-i", cntName, cmd}, args...) return exec.Command("docker", args...) diff --git a/runner.go b/runner.go index d8199a3..4857447 100644 --- a/runner.go +++ b/runner.go @@ -21,6 +21,7 @@ import ( "golang.org/x/xerrors" "go.coder.com/flog" + "go.coder.com/sail/internal/dockutil" ) // containerLogPath is the location of the code-server log. @@ -44,6 +45,12 @@ const ( proxyURLLabel = sailLabel + ".proxy_url" ) +// Docker labels for user configuration. +const ( + onStartLabel = "on_start" + projectRootLabel = "project_root" +) + // runner holds all the information needed to assemble a new sail container. // The runner stores itself as state on the container. // It enables quick iteration on a container with small modifications to it's config. @@ -68,6 +75,8 @@ type runner struct { // the container's root process. // We want code-server to be the root process as it gives us the nice guarantee that // the container is only online when code-server is working. +// Additionally, runContainer also runs the image's `on_start` label as a bash +// command inside of the project directory. func (r *runner) runContainer(image string) error { cli := dockerClient() defer cli.Close() @@ -131,6 +140,11 @@ func (r *runner) runContainer(image string) error { return xerrors.Errorf("failed to start container: %w", err) } + err = r.runOnStart(image) + if err != nil { + return xerrors.Errorf("failed to run on_start label in container: %w", err) + } + return nil } @@ -457,7 +471,7 @@ func (r *runner) projectDir(image string) (string, error) { return "", xerrors.Errorf("failed to inspect image: %w", err) } - proot, ok := img.Config.Labels["project_root"] + proot, ok := img.Config.Labels[projectRootLabel] if ok { return filepath.Join(proot, r.projectName), nil } @@ -491,6 +505,34 @@ func runnerFromContainer(name string) (*runner, error) { }, nil } +// runOnStart runs the image's `on_start` label in the container in the project directory. +func (r *runner) runOnStart(image string) error { + cli := dockerClient() + defer cli.Close() + + // Get project directory. + projectDir, err := r.projectDir(image) + if err != nil { + return err + } + projectDir = resolvePath(containerHome, projectDir) + + // Get on_start label from image. + img, _, err := cli.ImageInspectWithRaw(context.Background(), image) + if err != nil { + return xerrors.Errorf("failed to inspect image: %w", err) + } + onStartCmd, ok := img.Config.Labels[onStartLabel] + if !ok { + // No on_start label, so we quit early. + return nil + } + + // Execute the command detached in the container. + cmd := dockutil.DetachedExecDir(r.cntName, projectDir, "/bin/bash", "-c", onStartCmd) + return cmd.Run() +} + func (r *runner) forkProxy() error { var err error r.proxyURL, err = forkProxy(r.cntName) diff --git a/runner_test.go b/runner_test.go index cb83b31..201d249 100644 --- a/runner_test.go +++ b/runner_test.go @@ -1,15 +1,18 @@ package main import ( + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "go.coder.com/sail/internal/dockutil" ) func Test_runner(t *testing.T) { // Ensure that the testing environment won't conflict with any running sail projects. - requireProjectsNotRunning(t, "cdr/nbin", "cdr/flog", "cdr/bigdur", "cdr/sshcode") + requireProjectsNotRunning(t, "cdr/nbin", "cdr/flog", "cdr/bigdur", "cdr/sshcode", "cdr/cli") requireUbuntuDevImage(t) // labelChecker asserts that all of the correct labels @@ -73,6 +76,23 @@ func Test_runner(t *testing.T) { }) } + // containsFile ensures that a container contains a file. + // This is used for testing the on_start label. + containsFile := func(name, path string) func(*testing.T, *params) { + return func(t *testing.T, p *params) { + t.Run(name, func(t *testing.T) { + cntDir, err := p.proj.containerDir() + require.NoError(t, err) + cntDir = resolvePath(containerHome, cntDir) + + // Run the file existence check using /bin/sh. + cmdStr := fmt.Sprintf(`[ -f "%s" ]`, path) + err = dockutil.ExecDir(p.proj.cntName(), cntDir, "/bin/sh", "-c", cmdStr).Run() + require.NoError(t, err) + }) + } + } + run(t, "BaseImageNoHat", "https://github.com/cdr/nbin", "", labelChecker, codeServerStarts, @@ -96,4 +116,13 @@ func Test_runner(t *testing.T) { codeServerStarts, loadFromContainer, ) + + run(t, "ProjImageOnStartHat", "https://github.com/cdr/cli", "./hat-examples/on_start", + labelChecker, + codeServerStarts, + loadFromContainer, + + // ./hat-examples/on_start should create `did_on_start` in the project directory. + containsFile("ContainsOnStartFile", "did_on_start"), + ) } diff --git a/site/content/docs/concepts/labels.md b/site/content/docs/concepts/labels.md index d4df745..327e9b6 100644 --- a/site/content/docs/concepts/labels.md +++ b/site/content/docs/concepts/labels.md @@ -24,6 +24,30 @@ LABEL project_root "~/go/src/" Will bind mount the host directory `$project_root//` to `~/go/src/` in the container. +### On Start Labels + +You can run a command in your sail container after it starts by specifying +the `on_start` label. If you'd like to run multiple commands on launch, we +recommend using a `.sh` file as your `on_start` label, as you cannot +provide multiple `on_start` labels in your image. + +The `on_start` label is run detached inside of `/bin/bash` as soon as the +container is started, with the work directory set to your `project_root` +(see the section above). + +For example: +```Dockerfile +LABEL on_start "npm install" +``` +```Dockerfile +LABEL on_start "go get" +``` +```Dockerfile +LABEL on_start "./.sail/on_start.sh" +``` + +Make sure any scripts you make are executable, otherwise sail will fail to +launch. ### Share Labels diff --git a/versionmd.go b/versionmd.go index bcc71a2..402788d 100644 --- a/versionmd.go +++ b/versionmd.go @@ -9,7 +9,7 @@ import ( var version string -type versioncmd struct {} +type versioncmd struct{} func (v *versioncmd) Spec() cli.CommandSpec { return cli.CommandSpec{