Repository: dependabot/dependabot-core
Pull request #11765: Add support for environment variables with default value in docker-compose files
Author: login: andrcuns
Labels: name: L: docker:compose
description: Docker Compose
Created: 2025-03-08T12:14:00Z | Merged: 2025-03-20T12:43:05Z

### What are you trying to accomplish?

It is fairly common to parametrize image in docker-compose file via variable with default value. Currently environment variable in compose file is ignored completely.

This change allows to update image when it is defined as default value within environment variable.

### Anything you want to highlight for special attention from reviewers?

Most of the logic is around adjusting the regexes, so that is probably where most attention is required.

### How will you know you've accomplished your goal?

* Added unit tests for both file_parser and file_updater
* I also ran this change in an e2e scenario with my app that uses `dependabot-core`

### Checklist

<!-- Before requesting review, please ensure that your pull request fulfills the following requirements: -->

- [ ] I have run the complete test suite to ensure all tests and linters pass.
- [ ] I have thoroughly tested my code changes to ensure they work as expected, including adding additional tests for new functionality.
- [ ] I have written clear and descriptive commit messages.
- [ ] I have provided a detailed description of the changes in the pull request, including the problem it addresses, how it fixes the problem, and any relevant details about the implementation.
- [ ] I have ensured that the code is well-documented and easy to understand.



Diff:
diff --git a/docker_compose/lib/dependabot/docker_compose/file_parser.rb b/docker_compose/lib/dependabot/docker_compose/file_parser.rb
index 0af43c09310..b0f2e8aab74 100644
--- a/docker_compose/lib/dependabot/docker_compose/file_parser.rb
+++ b/docker_compose/lib/dependabot/docker_compose/file_parser.rb
@@ -10,7 +10,7 @@ module DockerCompose
     class FileParser < Dependabot::Shared::SharedFileParser
       extend T::Sig
 
-      ENV_VAR = /\${[^}]+}/
+      ENV_VAR = /\${(?<variable_name>[^}:]+)(?:\:-(?<default_value>[^}]+))?}/
       DIGEST = /(?<digest>[0-9a-f]{64})/
       IMAGE_REGEX = %r{^(#{REGISTRY}/)?#{IMAGE}#{TAG}?(?:@sha256:#{DIGEST})?#{NAME}?}x
 
@@ -65,10 +65,7 @@ def parse_image_spec(service)
         return nil unless service
 
         if service["image"]
-          return nil if service["image"].match?(/^\${[^}]+}$/)
-
-          match = IMAGE_REGEX.match(service["image"])
-          return match&.named_captures
+          return service_image(service["image"])
         elsif service["build"].is_a?(Hash) && service["build"]["dockerfile_inline"]
           return nil if service["build"]["dockerfile_inline"].match?(/^FROM\s+\${[^}]+}$/)
 
@@ -79,6 +76,20 @@ def parse_image_spec(service)
         nil
       end
 
+      sig { params(image: String).returns(T.nilable(T::Hash[String, T.nilable(String)])) }
+      def service_image(image)
+        docker_image = image
+
+        if image.match?(/^#{ENV_VAR}/o)
+          default_value = ENV_VAR.match(image)&.named_captures&.fetch("default_value")
+          return unless default_value
+
+          docker_image = default_value
+        end
+
+        IMAGE_REGEX.match(docker_image)&.named_captures
+      end
+
       sig { params(parsed_image: T::Hash[String, T.nilable(String)]).returns(T.nilable(String)) }
       def version_from(parsed_image)
         return nil if parsed_image["tag"]&.match?(ENV_VAR)
diff --git a/docker_compose/lib/dependabot/docker_compose/file_updater.rb b/docker_compose/lib/dependabot/docker_compose/file_updater.rb
index 5c99dc6c1f7..688e9117e99 100644
--- a/docker_compose/lib/dependabot/docker_compose/file_updater.rb
+++ b/docker_compose/lib/dependabot/docker_compose/file_updater.rb
@@ -34,7 +34,7 @@ def container_image_regex
 
       sig { override.params(escaped_declaration: String).returns(Regexp) }
       def build_old_declaration_regex(escaped_declaration)
-        %r{#{IMAGE_REGEX}\s+["']?(docker\.io/)?#{escaped_declaration}["']?(?=\s|$)}
+        %r{#{IMAGE_REGEX}\s+["']?(?:\$\{[^\}:]+:-)?(docker\.io/)?#{escaped_declaration}(?:\})?["']?(?=\s|$)}
       end
 
       sig { override.returns(T::Array[Dependabot::DependencyFile]) }
diff --git a/docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb b/docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb
index 45333c219b8..741b6b5a1fd 100644
--- a/docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb
+++ b/docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb
@@ -514,7 +514,7 @@
           content: <<~YAML
             services:
               api:
-                image: ubuntu:${TAG_VERSION}
+                image: ubuntu${TAG_VERSION}
               db:
                 image: postgres:12.3
               cache:
@@ -583,5 +583,25 @@
         end
       end
     end
+
+    context "with default value in variable" do
+      let(:dependency) { parser.parse.first }
+
+      let(:composefile) do
+        Dependabot::DependencyFile.new(
+          name: "docker-compose.yml",
+          content: <<~YAML
+            services:
+              api:
+                image: ${UBUNTU_IMAGE:-ubuntu:17.04}
+          YAML
+        )
+      end
+
+      it "returns dependency defined as default value" do
+        expect(dependency.name).to eq("ubuntu")
+        expect(dependency.version).to eq("17.04")
+      end
+    end
   end
 end
diff --git a/docker_compose/spec/dependabot/docker_compose/file_updater_spec.rb b/docker_compose/spec/dependabot/docker_compose/file_updater_spec.rb
index 40acd5f18dd..1229cf89742 100644
--- a/docker_compose/spec/dependabot/docker_compose/file_updater_spec.rb
+++ b/docker_compose/spec/dependabot/docker_compose/file_updater_spec.rb
@@ -590,5 +590,19 @@
         end
       end
     end
+
+    context "when dependency is default value of variable" do
+      let(:dockerfile_body) do
+        fixture("docker_compose", "composefiles", "variable")
+      end
+
+      describe "the updated docker-compose.yml" do
+        subject(:updated_dockerfile) do
+          updated_files.find { |f| f.name == "docker-compose.yml" }
+        end
+
+        its(:content) { is_expected.to include "image: ${UBUNTU_IMAGE:-ubuntu:17.10}\n" }
+      end
+    end
   end
 end
diff --git a/docker_compose/spec/fixtures/docker_compose/composefiles/variable b/docker_compose/spec/fixtures/docker_compose/composefiles/variable
new file mode 100644
index 00000000000..0c7c9240a39
--- /dev/null
+++ b/docker_compose/spec/fixtures/docker_compose/composefiles/variable
@@ -0,0 +1,5 @@
+version: '2'
+services:
+  hello_world:
+    image: ${UBUNTU_IMAGE:-ubuntu:17.04}
+    command: [/bin/echo, 'Hello world']


Reviews:
user: login: thavaahariharangit
body: LGTM
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:47:42Z
user: login: robaiken
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:30:16Z
user: login: andrcuns
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:48:11Z
user: login: robaiken
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:18:28Z

Review comments:
diff_hunk: @@ -583,5 +583,25 @@
         end
       end
     end
+
+    context "with default value in variable" do
+      let(:dependency) { parser.parse.first }
+
+      let(:composefile) do
+        Dependabot::DependencyFile.new(
+          name: "docker-compose.yml",
+          content: <<~YAML
+            services:
+              api:
+                image: ${UBUNTU_IMAGE:-ubuntu:17.04}
path: docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb
user: login: robaiken
body: Hey, apologies for the delay in reviewing this PR. Would you have time to add some test cases for other forms of interpolated image references? For example:

```
Copy${REDIS_IMAGE}
postgres:${POSTGRES_VERSION}
```

These would help ensure future changes don't break the regex pattern. Thanks!
created_at: 2025-03-20T11:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -583,5 +583,25 @@
         end
       end
     end
+
+    context "with default value in variable" do
+      let(:dependency) { parser.parse.first }
+
+      let(:composefile) do
+        Dependabot::DependencyFile.new(
+          name: "docker-compose.yml",
+          content: <<~YAML
+            services:
+              api:
+                image: ${UBUNTU_IMAGE:-ubuntu:17.04}
path: docker_compose/spec/dependabot/docker_compose/file_parser_spec.rb
user: login: andrcuns
body: @robaiken Those are already covered by existing tests.

I updated one of the tests to also cover `Copy${REDIS_IMAGE}`
created_at: 2025-03-20T11:48:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: robaiken
created_at: 2025-03-20T12:46:22Z
body: @andrcuns thank you so much for your contribution to Dependabot! Sorry for the slow response on our end. Just wanted to let you know your change is now live! Really appreciate your patience and the awesome improvement.
author_association: CONTRIBUTOR
user: login: andrcuns
created_at: 2025-03-20T13:06:05Z
body: > @andrcuns thank you so much for your contribution to Dependabot! Sorry for the slow response on our end. Just wanted to let you know your change is now live! Really appreciate your patience and the awesome improvement.

Thanks a bunch and no worries, I'm not in a hurry 😄 
author_association: CONTRIBUTOR