From a8611f5d80ee591b1c42375384ee95311c18c21f Mon Sep 17 00:00:00 2001
From: Alejandro Celaya <alejandrocelaya@gmail.com>
Date: Sat, 10 Feb 2024 09:54:59 +0100
Subject: [PATCH] Support loading env vars from secret files

---
 CHANGELOG.md                            |  2 +-
 bin/test/run-api-tests.sh               |  6 ++++--
 module/Core/src/Config/EnvVars.php      | 23 ++++++++++++++++++++++-
 module/Core/test/Config/EnvVarsTest.php | 10 ++++++++++
 module/Core/test/DB_PASSWORD.env        |  1 +
 5 files changed, 38 insertions(+), 4 deletions(-)
 create mode 100644 module/Core/test/DB_PASSWORD.env

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a0b0c9f..3093f52b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
 
 ## [Unreleased]
 ### Added
-* *Nothing*
+* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image.
 
 ### Changed
 * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware.
diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh
index b22a974e..6a1cfb46 100755
--- a/bin/test/run-api-tests.sh
+++ b/bin/test/run-api-tests.sh
@@ -20,6 +20,8 @@ echo 'Starting server...'
 [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d
 [ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \
   -o=http.address=0.0.0.0:9999 \
+  -o=http.pool.debug=false \
+  -o=jobs.pool.debug=false \
   -o=logs.encoding=json \
   -o=logs.channels.http.encoding=json \
   -o=logs.channels.server.encoding=json \
@@ -29,10 +31,10 @@ echo 'Starting server...'
 sleep 2 # Let's give the server a couple of seconds to start
 
 vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
-testsExitCode=$?
+TESTS_EXIT_CODE=$?
 
 [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
 [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999
 
 # Exit this script with the same code as the tests. If tests failed, this script has to fail
-exit $testsExitCode
+exit $TESTS_EXIT_CODE
diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php
index ff64838b..40f311e9 100644
--- a/module/Core/src/Config/EnvVars.php
+++ b/module/Core/src/Config/EnvVars.php
@@ -4,7 +4,11 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\Config;
 
+use function file_get_contents;
+use function is_file;
 use function Shlinkio\Shlink\Config\env;
+use function Shlinkio\Shlink\Config\parseEnvVar;
+use function sprintf;
 
 enum EnvVars: string
 {
@@ -77,7 +81,24 @@ enum EnvVars: string
 
     public function loadFromEnv(mixed $default = null): mixed
     {
-        return env($this->value, $default);
+        return env($this->value) ?? $this->loadFromFileEnv() ?? $default;
+    }
+
+    /**
+     * Checks if an equivalent environment variable exists with the `_FILE` suffix. If so, it loads its value as a file,
+     * reads it, and returns its contents.
+     * This is useful when loading Shlink with docker compose and using secrets.
+     * See https://docs.docker.com/compose/use-secrets/
+     */
+    private function loadFromFileEnv(): string|int|bool|null
+    {
+        $file = env(sprintf('%s_FILE', $this->value));
+        if ($file === null || ! is_file($file)) {
+            return null;
+        }
+
+        $content = file_get_contents($file);
+        return $content ? parseEnvVar($content) : null;
     }
 
     public function existsInEnv(): bool
diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php
index 0b012051..dd83393b 100644
--- a/module/Core/test/Config/EnvVarsTest.php
+++ b/module/Core/test/Config/EnvVarsTest.php
@@ -17,12 +17,16 @@ class EnvVarsTest extends TestCase
     {
         putenv(EnvVars::BASE_PATH->value . '=the_base_path');
         putenv(EnvVars::DB_NAME->value . '=shlink');
+
+        $envFilePath = __DIR__ . '/../DB_PASSWORD.env';
+        putenv(EnvVars::DB_PASSWORD->value . '_FILE=' . $envFilePath);
     }
 
     protected function tearDown(): void
     {
         putenv(EnvVars::BASE_PATH->value . '=');
         putenv(EnvVars::DB_NAME->value . '=');
+        putenv(EnvVars::DB_PASSWORD->value . '_FILE=');
     }
 
     #[Test, DataProvider('provideExistingEnvVars')]
@@ -54,4 +58,10 @@ class EnvVarsTest extends TestCase
         yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null];
         yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar'];
     }
+
+    #[Test]
+    public function fallsBackToReadEnvVarsFromFile(): void
+    {
+        self::assertEquals('this_is_the_password', EnvVars::DB_PASSWORD->loadFromEnv());
+    }
 }
diff --git a/module/Core/test/DB_PASSWORD.env b/module/Core/test/DB_PASSWORD.env
new file mode 100644
index 00000000..d5b7bed8
--- /dev/null
+++ b/module/Core/test/DB_PASSWORD.env
@@ -0,0 +1 @@
+this_is_the_password