Skip to content

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

@matiboux

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.

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