diff --git a/pkg/e2e/fixtures/watch/compose.yaml b/pkg/e2e/fixtures/watch/compose.yaml new file mode 100644 index 000000000..0c31c6290 --- /dev/null +++ b/pkg/e2e/fixtures/watch/compose.yaml @@ -0,0 +1,34 @@ +x-dev: &x-dev + watch: + - action: sync + path: ./data + target: /app/data + ignore: + - '*.foo' + - ./ignored + +services: + alpine: + build: + dockerfile_inline: |- + FROM alpine + RUN mkdir -p /app/data + init: true + command: sleep infinity + x-develop: *x-dev + busybox: + build: + dockerfile_inline: |- + FROM busybox + RUN mkdir -p /app/data + init: true + command: sleep infinity + x-develop: *x-dev + debian: + build: + dockerfile_inline: |- + FROM debian + RUN mkdir -p /app/data + init: true + command: sleep infinity + x-develop: *x-dev diff --git a/pkg/e2e/fixtures/watch/data/hello.txt b/pkg/e2e/fixtures/watch/data/hello.txt new file mode 100644 index 000000000..95d09f2b1 --- /dev/null +++ b/pkg/e2e/fixtures/watch/data/hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/pkg/e2e/watch_test.go b/pkg/e2e/watch_test.go new file mode 100644 index 000000000..71641139e --- /dev/null +++ b/pkg/e2e/watch_test.go @@ -0,0 +1,184 @@ +/* + Copyright 2023 Docker Compose CLI 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 e2e + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/distribution/distribution/v3/uuid" + "github.com/stretchr/testify/require" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/icmd" + "gotest.tools/v3/poll" +) + +func TestWatch(t *testing.T) { + services := []string{"alpine", "busybox", "debian"} + for _, svcName := range services { + t.Run(svcName, func(t *testing.T) { + t.Helper() + doTest(t, svcName) + }) + } +} + +// NOTE: these tests all share a single Compose file but are safe to run concurrently +func doTest(t *testing.T, svcName string) { + tmpdir := t.TempDir() + dataDir := filepath.Join(tmpdir, "data") + writeDataFile := func(name string, contents string) { + t.Helper() + dest := filepath.Join(dataDir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o700)) + t.Logf("writing %q to %q", contents, dest) + require.NoError(t, os.WriteFile(dest, []byte(contents+"\n"), 0o600)) + } + + composeFilePath := filepath.Join(tmpdir, "compose.yaml") + CopyFile(t, filepath.Join("fixtures", "watch", "compose.yaml"), composeFilePath) + + projName := "e2e-watch-" + svcName + env := []string{ + "COMPOSE_FILE=" + composeFilePath, + "COMPOSE_PROJECT_NAME=" + projName, + } + + cli := NewParallelCLI(t, WithEnv(env...)) + + cleanup := func() { + cli.RunDockerComposeCmd(t, "down", svcName, "--timeout=0", "--remove-orphans", "--volumes") + } + cleanup() + t.Cleanup(cleanup) + + cli.RunDockerComposeCmd(t, "up", svcName, "--wait", "--build") + + cmd := cli.NewDockerComposeCmd(t, "--verbose", "alpha", "watch", svcName) + // stream output since watch runs in the background + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + r := icmd.StartCmd(cmd) + require.NoError(t, r.Error) + t.Cleanup(func() { + // IMPORTANT: watch doesn't exit on its own, don't leak processes! + if r.Cmd.Process != nil { + _ = r.Cmd.Process.Kill() + } + }) + var testComplete atomic.Bool + go func() { + // if the process exits abnormally before the test is done, fail the test + if err := r.Cmd.Wait(); err != nil && !testComplete.Load() { + assert.Check(t, cmp.Nil(err)) + } + }() + + require.NoError(t, os.Mkdir(dataDir, 0o700)) + + checkFileContents := func(path string, contents string) poll.Check { + return func(pollLog poll.LogT) poll.Result { + if r.Cmd.ProcessState != nil { + return poll.Error(fmt.Errorf("watch process exited early: %s", r.Cmd.ProcessState)) + } + res := icmd.RunCmd(cli.NewDockerComposeCmd(t, "exec", svcName, "cat", path)) + if strings.Contains(res.Stdout(), contents) { + return poll.Success() + } + return poll.Continue(res.Combined()) + } + } + + waitForFlush := func() { + sentinelVal := uuid.Generate().String() + writeDataFile("wait.txt", sentinelVal) + poll.WaitOn(t, checkFileContents("/app/data/wait.txt", sentinelVal)) + } + + t.Logf("Writing to a file until Compose watch is up and running") + poll.WaitOn(t, func(t poll.LogT) poll.Result { + writeDataFile("hello.txt", "hello world") + return checkFileContents("/app/data/hello.txt", "hello world")(t) + }) + + t.Logf("Modifying file contents") + writeDataFile("hello.txt", "hello watch") + poll.WaitOn(t, checkFileContents("/app/data/hello.txt", "hello watch")) + + t.Logf("Deleting file") + require.NoError(t, os.Remove(filepath.Join(dataDir, "hello.txt"))) + waitForFlush() + cli.RunDockerComposeCmdNoCheck(t, "exec", svcName, "stat", "/app/data/hello.txt"). + Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "No such file or directory", + }, + ) + + t.Logf("Writing to ignored paths") + writeDataFile("data.foo", "ignored") + writeDataFile(filepath.Join("ignored", "hello.txt"), "ignored") + waitForFlush() + cli.RunDockerComposeCmdNoCheck(t, "exec", svcName, "stat", "/app/data/data.foo"). + Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "No such file or directory", + }, + ) + cli.RunDockerComposeCmdNoCheck(t, "exec", svcName, "stat", "/app/data/ignored"). + Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "No such file or directory", + }, + ) + + t.Logf("Creating subdirectory") + require.NoError(t, os.Mkdir(filepath.Join(dataDir, "subdir"), 0o700)) + waitForFlush() + cli.RunDockerComposeCmd(t, "exec", svcName, "stat", "/app/data/subdir") + + t.Logf("Writing to file in subdirectory") + writeDataFile(filepath.Join("subdir", "file.txt"), "a") + poll.WaitOn(t, checkFileContents("/app/data/subdir/file.txt", "a")) + + t.Logf("Writing to file multiple times") + writeDataFile(filepath.Join("subdir", "file.txt"), "x") + writeDataFile(filepath.Join("subdir", "file.txt"), "y") + writeDataFile(filepath.Join("subdir", "file.txt"), "z") + poll.WaitOn(t, checkFileContents("/app/data/subdir/file.txt", "z")) + writeDataFile(filepath.Join("subdir", "file.txt"), "z") + writeDataFile(filepath.Join("subdir", "file.txt"), "y") + writeDataFile(filepath.Join("subdir", "file.txt"), "x") + poll.WaitOn(t, checkFileContents("/app/data/subdir/file.txt", "x")) + + t.Logf("Deleting directory") + require.NoError(t, os.RemoveAll(filepath.Join(dataDir, "subdir"))) + waitForFlush() + cli.RunDockerComposeCmdNoCheck(t, "exec", svcName, "stat", "/app/data/subdir"). + Assert(t, icmd.Expected{ + ExitCode: 1, + Err: "No such file or directory", + }, + ) + + testComplete.Store(true) +}