Skip to content

[BUG] Edge-case checking path prefix in watch for bind mount volumes #12639

Closed

Description

@matiboux

Description

Hello, I noticed an edge case bug in the code that validates paths in compose watch.

Indeed, the code checks whether any bind mount volume is a prefix to the watched path, but doing so naively causes it to falsy assumes that a sibling directory with a similar name is a prefix as well.

For example, this is one bind mount volume falsely considered as a prefix to the watched path:

  • Bind mount volume: /home/user/dir
  • Watched path: /home/user/dir2

This is checked in pkg/compose/watch.go here:

if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {

With the underlying function checkIfPathAlreadyBindMounted checking this:

if volume.Bind != nil && strings.HasPrefix(watchPath, volume.Source) {

This would compute strings.HasPrefix("/home/user/dir2", "/home/user/dir"), which is true but not the expected behavior.

Steps To Reproduce

Making it a bit fun...

Demonstrating a false positive conflict between:

  • A bind mount volume: ./data
  • A watched path: ./data-init

Generate files to reproduce my example environment:

# Create a new directory in which to run the test
mkdir -p ./test-issue
cd ./test-issue

# Create the directories
mkdir -p ./app
mkdir -p ./data
mkdir -p ./data-init

# Create the Compose file
cat <<'EOF' > ./compose.yml
services:
  data-init:
    # Build
    build:
      context: .
      dockerfile_inline: |
        FROM alpine:3.21
        WORKDIR /app
        COPY --link ./data-init .
        CMD [ "sh", "./init.sh" ]
    # Deploy
    environment:
      LOOP_INIT: "${LOOP_INIT:-false}"
    volumes:
      - ./data:/data
    develop:
      watch:
        # Docker image
        - action: sync
          path: ./data-init
          target: /app
  app:
    # Build
    build:
      context: .
      dockerfile_inline: |
        FROM alpine:3.21
        WORKDIR /app
        COPY --link ./app .
        CMD [ "sh", "./hello.sh" ]
    # Deploy
    depends_on:
      data-init:
        condition: service_completed_successfully
    environment:
      LOOP_APP: "${LOOP_APP:-false}"
    volumes:
      - ./data:/data
    develop:
      watch:
        # Docker image
        - action: sync
          path: ./app
          target: /app
EOF

# Create ./app/hello.sh start script for the app service
cat <<'EOF' > ./app/hello.sh
#!/bin/sh

echo "Hello, there!"

FIRST_DATA_FILE=$(ls /data | head -n 1)
if [ -z "$FIRST_DATA_FILE" ]; then
  echo "I found no data in /data."
  echo "I will cry now."
  exit 1
fi

echo "I found a file in /data: $FIRST_DATA_FILE"
echo "It contains..."

FIRST_DATA_FILE_CONTENTS=$(cat /data/$FIRST_DATA_FILE)
echo "'$FIRST_DATA_FILE_CONTENTS'"

echo "Great."

if [ "$LOOP_APP" = "true" ]; then
  echo "I will loop forever now."
  while true; do
    sleep 1
  done
fi
EOF

# Create ./data-init/init.sh start script for the data-init service
cat <<'EOF' > ./data-init/init.sh
#!/bin/sh

echo "Hohoho!"

rm -rf /data/*
echo "I removed all the data in /data."

RANDOM_FILE_NAME=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 8)
echo "Some data in a random file!" > /data/$RANDOM_FILE_NAME.txt

echo "I dropped some data in a random file: /data/$RANDOM_FILE_NAME.txt"
echo "Goodbye!"

if [ "$LOOP_INIT" = "true" ]; then
  echo "I will loop forever now."
  while true; do
    sleep 1
  done
fi
EOF

Then, let's check it works:

docker compose up

We get the following output:

[+] Running 5/5
 ✔ app                               Built                       0.0s 
 ✔ data-init                         Built                       0.0s 
 ✔ Network test-issue_default        Created                     0.0s 
 ✔ Container test-issue-data-init-1  Created                     0.0s 
 ✔ Container test-issue-app-1        Created                     0.0s 
Attaching to app-1, data-init-1
data-init-1  | Hohoho!
data-init-1  | I removed all the data in /data.
data-init-1  | I dropped some data in a random file: /data/AfYFPeAm.txt
data-init-1  | Goodbye!
data-init-1 exited with code 0
app-1        | Hello, there!
app-1        | I found a file in /data: AfYFPeAm.txt
app-1        | It contains...
app-1        | 'Some data in a random file!'
app-1        | Great.
app-1 exited with code 0

Now, to see compose watch alert us about the bind mount volume, we can run:

docker compose up --watch

We get the following output:

[+] Running 2/2
 ✔ Container test-issue-data-init-1  Created                     0.0s 
 ✔ Container test-issue-app-1        Created                     0.0s 
WARN[0000] path '/[...]/test-issue/data-init' also declared by a bind mount volume, this path won't be monitored! 
        ⦿ Watch enabled
data-init-1  | Hohoho!
data-init-1  | I removed all the data in /data.
data-init-1  | I dropped some data in a random file: /data/2lJfV7dw.txt
data-init-1  | Goodbye!
data-init-1 exited with code 0
app-1        | Hello, there!
app-1        | I found a file in /data: JDGsDWEk.txt
app-1        | It contains...
app-1        | 'Some data in a random file!'
app-1        | Great.
app-1 exited with code 0

To test live that the watch path is ignored, I've added env vars to freeze a service in a loop.

Freezing the app service:

LOOP_APP=true docker compose up --watch

We get the following output:

[+] Running 2/2
 ✔ Container test-issue-data-init-1  Recreated                   0.1s 
 ✔ Container test-issue-app-1        Recreated                   0.1s 
WARN[0000] path '/[...]/test-issue/data-init' also declared by a bind mount volume, this path won't be monitored! 
        ⦿ Watch enabled
data-init-1  | Hohoho!
data-init-1  | I removed all the data in /data.
data-init-1  | I dropped some data in a random file: /data/2lJfV7dw.txt
data-init-1  | Goodbye!
data-init-1 exited with code 0
app-1        | Hello, there!
app-1        | I found a file in /data: 2lJfV7dw.txt
app-1        | It contains...
app-1        | 'Some data in a random file!'
app-1        | Great.
app-1        | I will loop forever now.

Now, if we modify the scripts...

  • Modifying ./app/hello.sh triggers compose watch: ⦿ Syncing service "app" after 1 changes were detected
  • Modifying ./data-init/init.sh does nothing!

Finally, freezing the data-init service:

LOOP_INIT=true docker compose up --watch

We get the following output:

[+] Running 2/2
 ✔ Container test-issue-app-1        Recreated                   0.1s 
 ✔ Container test-issue-data-init-1  Recreated                   0.1s 
WARN[0000] path '/[...]/test-issue/data-init' also declared by a bind mount volume, this path won't be monitored! 
        ⦿ Watch enabled
Attaching to app-1, data-init-1
data-init-1  | Hohoho!
data-init-1  | I removed all the data in /data.
data-init-1  | I dropped some data in a random file: /data/aX2QXT8x.txt
data-init-1  | Goodbye!
data-init-1  | I will loop forever now.

Then, if we modify the scripts...

  • Modifying ./app/hello.sh does still trigger compose watch: ⦿ Syncing service "app" after 1 changes were detected
  • Modifying ./data-init/init.sh does nothing again!

Compose Version

Docker Compose version v2.33.1

Docker Environment

(`docker -v` ought to be enough...)
Docker version 28.0.1, build 068a01e

Anything else?

I'll create a PR to propose a fix for this issue, as I already found the problematic code and this should be a simple fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions