From 6099035e25c204c68d8f86af25a1781cadb03dc2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 15:47:24 +0000 Subject: [PATCH 1/8] Reproduce ignored database TLS configuration inputs --- .../Feature/DatabaseTlsConfigurationTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/Feature/DatabaseTlsConfigurationTest.php diff --git a/tests/Feature/DatabaseTlsConfigurationTest.php b/tests/Feature/DatabaseTlsConfigurationTest.php new file mode 100644 index 000000000..a7d0bbf2d --- /dev/null +++ b/tests/Feature/DatabaseTlsConfigurationTest.php @@ -0,0 +1,47 @@ +get($key); + $repository->clear($key); + if (isset($environment[$key])) { + $repository->set($key, $environment[$key]); + } + } + + try { + $database = require base_path('config/database.php'); + self::assertSame($mode, $database['connections'][$driver]['sslmode'] ?? null); + self::assertSame($certificate, $database['connections'][$driver]['sslrootcert'] ?? null); + } finally { + foreach ($original as $key => $value) { + $repository->clear($key); + if ($value !== null) { + $repository->set($key, $value); + } + } + } + } + + public static function databaseTlsInputs(): array + { + return [ + 'postgres canonical names' => [['DB_SSLMODE' => 'verify-full', 'DB_SSLROOTCERT' => '/certs/ca.pem'], 'pgsql', 'verify-full', '/certs/ca.pem'], + 'postgres existing chart input' => [['DB_SSL_MODE' => 'require'], 'pgsql', 'require', null], + 'mysql required encryption' => [['DB_SSLMODE' => 'require'], 'mysql', 'require', null], + 'mariadb verified encryption' => [['DB_SSLMODE' => 'verify-full', 'DB_SSLROOTCERT' => '/certs/ca.pem'], 'mariadb', 'verify-full', '/certs/ca.pem'], + ]; + } +} From 7f1eb04e5f35b3b349ef5b0bd7b6e7cb47fd2a5d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:20:00 +0000 Subject: [PATCH 2/8] Enforce configured PHP database TLS and report connection guarantees --- app/Database/DatabaseTlsConfiguration.php | 180 ++++++++++++++++++ app/Database/DatabaseTlsRefused.php | 11 ++ app/Database/RefusesRustDatabase.php | 15 +- app/Support/ServerReadiness.php | 7 +- config/database.php | 19 +- scripts/ci/database-tls-probe.php | 58 ++++++ .../Feature/DatabaseTlsConfigurationTest.php | 60 ++++++ 7 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 app/Database/DatabaseTlsConfiguration.php create mode 100644 app/Database/DatabaseTlsRefused.php create mode 100644 scripts/ci/database-tls-probe.php diff --git a/app/Database/DatabaseTlsConfiguration.php b/app/Database/DatabaseTlsConfiguration.php new file mode 100644 index 000000000..148e6e74e --- /dev/null +++ b/app/Database/DatabaseTlsConfiguration.php @@ -0,0 +1,180 @@ += 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA; + $certificate = self::certificate(['sslrootcert' => $config['options'][$ca] ?? null]); + $config['sslrootcert'] = $certificate; + } + if (in_array($mode, ['verify-ca', 'verify-full'], true) && $certificate === null) { + self::invalid('Certificate verification requires a readable CA bundle in DB_SSLROOTCERT.'); + } + + if ($driver === 'pgsql') { + return $config; + } + + return self::mysqlOptions($config, $mode, $certificate); + } + + public static function snapshot(array $config): array + { + $config = self::configure($config); + $mode = $config['sslmode'] ?? null; + $network = in_array($config['driver'] ?? null, ['pgsql', 'mysql', 'mariadb'], true); + $required = in_array($mode, ['require', 'verify-ca', 'verify-full'], true); + + return [ + 'mode' => $network ? $mode : null, + 'encrypted' => $required ? true : ($mode === 'disable' ? false : null), + 'ca_verified' => $required ? in_array($mode, ['verify-ca', 'verify-full'], true) : null, + 'hostname_verified' => $required ? $mode === 'verify-full' : null, + 'evidence' => $required ? 'successful_driver_enforced_connection' : 'not_verified', + 'scope' => 'php_to_configured_database_endpoint', + ]; + } + + private static function mode(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + if (! is_string($value)) { + self::invalid('The database TLS mode must be a string.'); + } + + return match (strtolower(trim($value))) { + 'disabled' => 'disable', + 'preferred' => 'prefer', + 'required' => 'require', + 'verify_ca' => 'verify-ca', + 'verify_identity', 'verify-identity' => 'verify-full', + default => strtolower(trim($value)), + }; + } + + private static function certificate(array $config): ?string + { + $certificate = $config['sslrootcert'] ?? null; + $alias = $config['sslrootcert_alias'] ?? null; + $requested = $config['sslrootcert_requested'] ?? null; + if ($alias !== null && $alias !== '' && $certificate !== null && $certificate !== $alias) { + self::invalid('DB_SSLROOTCERT and DB_SSL_ROOT_CERT name different CA bundles.'); + } + $certificate ??= $alias; + if ($requested !== null && $requested !== '' && $certificate !== null && $certificate !== $requested) { + self::invalid('The database URL conflicts with DB_SSLROOTCERT.'); + } + if ($certificate === null || $certificate === '') { + return null; + } + if (! is_string($certificate) || strpbrk($certificate, ";\r\n\0") !== false || ! is_file($certificate) || ! is_readable($certificate)) { + self::invalid('The configured database CA bundle must be a readable regular file.'); + } + + return $certificate; + } + + private static function requireTcp(array $config): void + { + $host = $config['host'] ?? ''; + if (! is_string($host) || trim($host) === '' || ($config['unix_socket'] ?? '') !== '') { + self::invalid('Required database TLS needs an explicit TCP host, not a Unix socket.'); + } + foreach (explode(',', $host) as $endpoint) { + $endpoint = trim($endpoint); + if (str_starts_with($endpoint, '/') || str_starts_with($endpoint, '@') || (in_array($config['driver'], ['mysql', 'mariadb'], true) && strtolower($endpoint) === 'localhost')) { + self::invalid('Required database TLS needs an explicit TCP host, not a Unix socket.'); + } + } + } + + private static function mysqlOptions(array $config, string $mode, ?string $certificate): array + { + if (! extension_loaded('pdo_mysql') || ! extension_loaded('mysqlnd') || ! extension_loaded('openssl')) { + self::invalid('Explicit MySQL/MariaDB TLS requires PDO MySQL with mysqlnd and OpenSSL.'); + } + $ca = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA; + $cipher = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CIPHER : PDO::MYSQL_ATTR_SSL_CIPHER; + $verify = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT; + $options = $config['options'] ?? []; + $legacyCertificate = $options[$ca] ?? null; + if ($certificate !== null && $legacyCertificate !== null && $certificate !== $legacyCertificate) { + self::invalid('DB_SSLROOTCERT and MYSQL_ATTR_SSL_CA name different CA bundles.'); + } + if ($mode === 'disable') { + if ($certificate !== null || $legacyCertificate !== null || isset($options[$cipher])) { + self::invalid('Disabled database TLS conflicts with configured TLS connection options.'); + } + + return $config; + } + + // A nonempty TLS option sets mysqlnd CLIENT_SSL before authentication. + // mysqlnd refuses a server without TLS instead of retrying plaintext. + $options[$cipher] ??= 'DEFAULT'; + $options[$verify] = $mode === 'verify-full'; + if ($certificate !== null) { + $options[$ca] = $certificate; + } + $config['options'] = $options; + + return $config; + } + + private static function invalid(string $message): never + { + throw new DatabaseTlsRefused('database_tls_configuration_invalid', $message); + } +} diff --git a/app/Database/DatabaseTlsRefused.php b/app/Database/DatabaseTlsRefused.php new file mode 100644 index 000000000..70717b618 --- /dev/null +++ b/app/Database/DatabaseTlsRefused.php @@ -0,0 +1,11 @@ +getAttribute(PDO::ATTR_ERRMODE); $connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); try { diff --git a/app/Support/ServerReadiness.php b/app/Support/ServerReadiness.php index 9dbedf537..291d8b7b1 100644 --- a/app/Support/ServerReadiness.php +++ b/app/Support/ServerReadiness.php @@ -5,6 +5,8 @@ use App\Auth\AuthException; use App\Auth\ConfiguredAuthProvider; use App\Contracts\AuthProvider; +use App\Database\DatabaseTlsConfiguration; +use App\Database\DatabaseTlsRefused; use App\Models\WorkflowNamespace; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -111,13 +113,16 @@ private function databaseCheck(bool $probeQuery = false): array 'status' => 'ok', 'check_scope' => $probeQuery ? 'bound_read_query' : 'connection_only', 'write_capacity_verified' => false, + 'tls' => DatabaseTlsConfiguration::snapshot($connection->getConfig()), 'message' => $probeQuery ? 'The primary database connection can execute a bound read query. Write capacity has not been verified.' : 'The database connection is available. Write capacity has not been verified.', ]; } catch (\Throwable $exception) { $reason = $queryAttempted ? 'database_query_failed' : null; - if (BackendUnavailable::is($exception)) { + if ($exception instanceof DatabaseTlsRefused) { + $reason = $exception->reason; + } elseif (BackendUnavailable::is($exception)) { $reason = 'backend_unavailable'; } diff --git a/config/database.php b/config/database.php index fd1f198af..f5b2a3b59 100644 --- a/config/database.php +++ b/config/database.php @@ -74,6 +74,12 @@ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, + 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')), + 'sslmode_requested' => env('DB_SSLMODE'), + 'sslmode_alias' => env('DB_SSL_MODE'), + 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), + 'sslrootcert_requested' => env('DB_SSLROOTCERT'), + 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), 'options' => $mysqlOptions, ], @@ -92,6 +98,12 @@ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, + 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')), + 'sslmode_requested' => env('DB_SSLMODE'), + 'sslmode_alias' => env('DB_SSL_MODE'), + 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), + 'sslrootcert_requested' => env('DB_SSLROOTCERT'), + 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), 'options' => $mysqlOptions, ], @@ -107,7 +119,12 @@ 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', - 'sslmode' => 'prefer', + 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE', 'prefer')), + 'sslmode_requested' => env('DB_SSLMODE'), + 'sslmode_alias' => env('DB_SSL_MODE'), + 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), + 'sslrootcert_requested' => env('DB_SSLROOTCERT'), + 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), // Keep native parameter binding without session-scoped statement // names, so transaction poolers can safely change backend sessions. 'options' => $pgsqlOptions, diff --git a/scripts/ci/database-tls-probe.php b/scripts/ci/database-tls-probe.php new file mode 100644 index 000000000..4e04002e9 --- /dev/null +++ b/scripts/ci/database-tls-probe.php @@ -0,0 +1,58 @@ +make(Kernel::class); +$request = Request::create('/api/ready'); +$response = $kernel->handle($request); +$payload = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); +$database = $payload['checks']['database'] ?? []; +$tls = $database['tls'] ?? []; +$transport = null; +if ($expected !== 'refused' && ($database['status'] ?? '') === 'ok') { + $connection = $app->make('db')->connection(); + if ($connection->getDriverName() === 'pgsql') { + $row = $connection->selectOne('SELECT ssl, version, cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid()', [], useReadPdo: false); + $transport = ['encrypted' => in_array($row->ssl ?? null, [true, 1, '1', 't'], true), 'protocol' => $row->version ?? null, 'cipher' => $row->cipher ?? null]; + } else { + $cipher = $connection->selectOne("SHOW SESSION STATUS LIKE 'Ssl_cipher'", [], useReadPdo: false); + $version = $connection->selectOne("SHOW SESSION STATUS LIKE 'Ssl_version'", [], useReadPdo: false); + $transport = ['encrypted' => ($cipher->Value ?? '') !== '', 'protocol' => $version->Value ?? null, 'cipher' => $cipher->Value ?? null]; + } +} + +$valid = $expected === 'refused' + ? $response->getStatusCode() === 503 + && ($database['status'] ?? '') === 'unavailable' + && in_array($database['reason'] ?? '', ['database_tls_configuration_invalid', 'database_tls_connection_failed'], true) + : $response->getStatusCode() === 200 + && ($database['status'] ?? '') === 'ok' + && ($tls['encrypted'] ?? null) === ($expected !== 'plaintext') + && ($tls['ca_verified'] ?? null) === ($expected === 'plaintext' ? null : $expected === 'verified') + && ($tls['hostname_verified'] ?? null) === ($expected === 'plaintext' ? null : $expected === 'verified') + && ($transport['encrypted'] ?? null) === ($expected !== 'plaintext') + && ($tls['scope'] ?? '') === 'php_to_configured_database_endpoint'; + +echo json_encode([ + 'expected' => $expected, + 'pass' => $valid, + 'http_status' => $response->getStatusCode(), + 'database_status' => $database['status'] ?? null, + 'reason' => $database['reason'] ?? null, + 'tls' => $tls, + 'direct_backend_fixture_transport' => $transport, + 'blocked_checks' => array_keys(array_filter($payload['checks'] ?? [], static fn (array $check): bool => ! in_array($check['status'] ?? null, ['ok', 'warning'], true))), +], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)."\n"; +$kernel->terminate($request, $response); +exit($valid ? 0 : 1); diff --git a/tests/Feature/DatabaseTlsConfigurationTest.php b/tests/Feature/DatabaseTlsConfigurationTest.php index a7d0bbf2d..1ab28b957 100644 --- a/tests/Feature/DatabaseTlsConfigurationTest.php +++ b/tests/Feature/DatabaseTlsConfigurationTest.php @@ -2,7 +2,10 @@ namespace Tests\Feature; +use App\Database\DatabaseTlsConfiguration; +use App\Database\DatabaseTlsRefused; use Illuminate\Support\Env; +use PDO; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -44,4 +47,61 @@ public static function databaseTlsInputs(): array 'mariadb verified encryption' => [['DB_SSLMODE' => 'verify-full', 'DB_SSLROOTCERT' => '/certs/ca.pem'], 'mariadb', 'verify-full', '/certs/ca.pem'], ]; } + + #[DataProvider('invalidTlsConfigurations')] + public function test_invalid_tls_configuration_refuses_before_a_connection(array $configuration): void + { + $this->expectException(DatabaseTlsRefused::class); + $this->expectExceptionCode(0); + DatabaseTlsConfiguration::configure($configuration + ['driver' => 'pgsql', 'host' => 'database.test']); + } + + public static function invalidTlsConfigurations(): array + { + return [ + 'unknown mode' => [['sslmode' => 'silently-insecure']], + 'conflicting chart alias' => [['sslmode' => 'verify-full', 'sslmode_alias' => 'disable']], + 'URL cannot downgrade explicit mode' => [['sslmode' => 'disable', 'sslmode_requested' => 'verify-full']], + 'URL cannot replace explicit CA' => [['sslmode' => 'verify-full', 'sslrootcert' => '/wrong.pem', 'sslrootcert_requested' => '/original.pem']], + 'missing verifying CA' => [['sslmode' => 'verify-full']], + 'unreadable CA' => [['sslmode' => 'verify-full', 'sslrootcert' => '/not-a-real-database-ca.pem']], + 'postgres Unix socket' => [['sslmode' => 'require', 'host' => '/var/run/postgresql']], + 'postgres abstract socket' => [['sslmode' => 'require', 'host' => '@postgres']], + 'mysql implicit Unix socket' => [['driver' => 'mysql', 'sslmode' => 'require', 'host' => 'localhost']], + 'mysql explicit Unix socket' => [['driver' => 'mysql', 'sslmode' => 'require', 'unix_socket' => '/tmp/mysql.sock']], + 'unsupported MySQL chain-only verification' => [['driver' => 'mysql', 'sslmode' => 'verify-ca']], + ]; + } + + public function test_required_mysql_tls_keeps_the_false_verification_option_and_requests_encryption(): void + { + $configuration = DatabaseTlsConfiguration::configure(['driver' => 'mysql', 'host' => 'database.test', 'sslmode' => 'required']); + self::assertSame('require', $configuration['sslmode']); + self::assertSame('DEFAULT', $configuration['options'][PDO::MYSQL_ATTR_SSL_CIPHER]); + self::assertFalse($configuration['options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]); + } + + public function test_legacy_mysql_ca_can_supply_an_explicit_verified_connection(): void + { + $certificate = tempnam(sys_get_temp_dir(), 'database-ca-'); + try { + $configuration = DatabaseTlsConfiguration::configure([ + 'driver' => 'mysql', 'host' => 'database.test', 'sslmode' => 'verify-full', + 'options' => [PDO::MYSQL_ATTR_SSL_CA => $certificate], + ]); + self::assertSame($certificate, $configuration['sslrootcert']); + self::assertTrue($configuration['options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]); + } finally { + unlink($certificate); + } + } + + public function test_unconfigured_encryption_is_unknown_rather_than_reported_safe(): void + { + $snapshot = DatabaseTlsConfiguration::snapshot(['driver' => 'pgsql', 'host' => 'database.test', 'sslmode' => 'prefer']); + self::assertNull($snapshot['encrypted']); + self::assertNull($snapshot['ca_verified']); + self::assertNull($snapshot['hostname_verified']); + self::assertSame('not_verified', $snapshot['evidence']); + } } From c62332e9bdff1b72c9f1afb8ff8063907e4ee0d5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:42:34 +0000 Subject: [PATCH 3/8] Wire CA bundles to every PHP role and qualify database TLS in CI --- .github/workflows/helm-chart-checks.yml | 5 + .github/workflows/helm-chart-validation.yml | 1 + .github/workflows/phpunit-feature.yml | 14 ++ app/Database/DatabaseTlsConfiguration.php | 8 +- config/database.php | 31 ++--- docs/helm-validation.md | 11 +- docs/server-reference.md | 49 ++++++- k8s/helm/durable-workflow/README.md | 32 +++++ .../ci/database-tls-values.yaml | 13 ++ k8s/helm/durable-workflow/docs/UPGRADING.md | 20 +++ .../templates/_database-tls.tpl | 39 ++++++ .../durable-workflow/templates/_helpers.tpl | 6 +- .../durable-workflow/templates/configmap.yaml | 6 +- .../templates/migration-job.yaml | 8 ++ .../templates/scheduler-cronjob.yaml | 8 ++ .../templates/server-deployment.yaml | 10 +- .../templates/worker-deployment.yaml | 10 +- k8s/helm/durable-workflow/values.schema.json | 7 + k8s/helm/durable-workflow/values.yaml | 4 + scripts/ci/database-tls-compose.yaml | 121 ++++++++++++++++++ scripts/ci/database-tls-probe.php | 8 +- scripts/ci/qualify-database-tls.sh | 81 ++++++++++++ scripts/ci/test_database_tls_chart.py | 95 ++++++++++++++ 23 files changed, 557 insertions(+), 30 deletions(-) create mode 100644 k8s/helm/durable-workflow/ci/database-tls-values.yaml create mode 100644 k8s/helm/durable-workflow/templates/_database-tls.tpl create mode 100644 scripts/ci/database-tls-compose.yaml create mode 100644 scripts/ci/qualify-database-tls.sh create mode 100644 scripts/ci/test_database_tls_chart.py diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 963e4eb87..cec758e44 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -61,6 +61,11 @@ jobs: echo "::endgroup::" done + - name: Check database CA mounts and bootstrap configuration + run: | + python3 -m pip install PyYAML==6.0.2 + python3 scripts/ci/test_database_tls_chart.py + - name: Render every CI fixture run: | mkdir -p rendered diff --git a/.github/workflows/helm-chart-validation.yml b/.github/workflows/helm-chart-validation.yml index cf29c7ecf..439d676f7 100644 --- a/.github/workflows/helm-chart-validation.yml +++ b/.github/workflows/helm-chart-validation.yml @@ -9,6 +9,7 @@ on: - "scripts/ci/helm_chart_release.py" - "scripts/ci/cleanup-kind-cluster.sh" - "scripts/ci/test_helm_chart_release.py" + - "scripts/ci/test_database_tls_chart.py" - "uv.toml" - ".github/workflows/helm-chart-checks.yml" - ".github/workflows/helm-chart-validation.yml" diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index d403c73ed..fc2a1ef9d 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -112,6 +112,10 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 25 + env: + TLS_TEST_IMAGE: durable-workflow-server-phpunit-base + TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} steps: - name: Require central action policy preflight @@ -298,6 +302,16 @@ jobs: docker volume rm -f "${DW_FIRST_RUN_SCOPE}-database" >/dev/null 2>&1 || true docker image rm durable-workflow-server-context-candidate >/dev/null 2>&1 || true + - name: Verify required database TLS and certificate refusals + run: timeout 600s bash scripts/ci/qualify-database-tls.sh + + - name: Clean up database TLS fixtures + if: ${{ always() }} + run: | + docker ps -aq --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker rm -f + docker network ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker network rm + rm -rf -- "$TLS_TEST_ROOT" + - name: Clean up test services if: always() run: | diff --git a/app/Database/DatabaseTlsConfiguration.php b/app/Database/DatabaseTlsConfiguration.php index 148e6e74e..2cba847bd 100644 --- a/app/Database/DatabaseTlsConfiguration.php +++ b/app/Database/DatabaseTlsConfiguration.php @@ -74,10 +74,16 @@ public static function snapshot(array $config): array $mode = $config['sslmode'] ?? null; $network = in_array($config['driver'] ?? null, ['pgsql', 'mysql', 'mariadb'], true); $required = in_array($mode, ['require', 'verify-ca', 'verify-full'], true); + $encrypted = null; + if ($required) { + $encrypted = true; + } elseif ($mode === 'disable') { + $encrypted = false; + } return [ 'mode' => $network ? $mode : null, - 'encrypted' => $required ? true : ($mode === 'disable' ? false : null), + 'encrypted' => $encrypted, 'ca_verified' => $required ? in_array($mode, ['verify-ca', 'verify-full'], true) : null, 'hostname_verified' => $required ? $mode === 'verify-full' : null, 'evidence' => $required ? 'successful_driver_enforced_connection' : 'not_verified', diff --git a/config/database.php b/config/database.php index f5b2a3b59..030c61c21 100644 --- a/config/database.php +++ b/config/database.php @@ -16,6 +16,15 @@ $pgsqlOptions = [$disablePreparesAttribute => true]; } +$databaseTls = [ + 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')), + 'sslmode_requested' => env('DB_SSLMODE'), + 'sslmode_alias' => env('DB_SSL_MODE'), + 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), + 'sslrootcert_requested' => env('DB_SSLROOTCERT'), + 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), +]; + return [ /* @@ -74,12 +83,7 @@ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')), - 'sslmode_requested' => env('DB_SSLMODE'), - 'sslmode_alias' => env('DB_SSL_MODE'), - 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), - 'sslrootcert_requested' => env('DB_SSLROOTCERT'), - 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), + ...$databaseTls, 'options' => $mysqlOptions, ], @@ -98,12 +102,7 @@ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')), - 'sslmode_requested' => env('DB_SSLMODE'), - 'sslmode_alias' => env('DB_SSL_MODE'), - 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), - 'sslrootcert_requested' => env('DB_SSLROOTCERT'), - 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), + ...$databaseTls, 'options' => $mysqlOptions, ], @@ -119,12 +118,8 @@ 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', - 'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE', 'prefer')), - 'sslmode_requested' => env('DB_SSLMODE'), - 'sslmode_alias' => env('DB_SSL_MODE'), - 'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')), - 'sslrootcert_requested' => env('DB_SSLROOTCERT'), - 'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'), + ...$databaseTls, + 'sslmode' => $databaseTls['sslmode'] ?? 'prefer', // Keep native parameter binding without session-scoped statement // names, so transaction poolers can safely change backend sessions. 'options' => $pgsqlOptions, diff --git a/docs/helm-validation.md b/docs/helm-validation.md index 04b7f65b3..4609aa681 100644 --- a/docs/helm-validation.md +++ b/docs/helm-validation.md @@ -127,17 +127,24 @@ touches `k8s/helm/**` or the chart smoke script. The harness has two jobs: - runs `helm upgrade` in place and re-checks readiness; - runs `helm uninstall` and asserts no chart-owned resources remain. -The CI fixtures intentionally cover three shapes: +The CI fixtures intentionally cover four shapes: | Fixture | Purpose | | --- | --- | | `ci/inline-secrets-values.yaml` | Smallest renderable chart — exercises secret rendering. | | `ci/existing-secrets-values.yaml` | GitOps / externally-managed-secret path. The chart renders no Secret resources of its own. | | `ci/ingress-and-hpa-values.yaml` | Optional templates: Ingress, HPA, NetworkPolicy, server + worker PDB. | +| `ci/database-tls-values.yaml` | External CA Secret, canonical TLS settings and read-only mounts in every PHP role. | -A chart change that affects rendered manifests must keep all three +A chart change that affects rendered manifests must keep all four fixtures green, and the kind smoke must end with a clean uninstall. +`scripts/ci/test_database_tls_chart.py` also checks both pre-install-hook and +ordinary bootstrap ordering, CA path/key consistency, and preservation of +application volumes with or without a database CA. A rendered mount proves +wiring, not a successful authenticated connection; actual database handshake +and published-image qualification remain separate checks. + ## Distribution Releases are cut from `Chart.yaml.version`. Each release publishes an OCI diff --git a/docs/server-reference.md b/docs/server-reference.md index 2c3fe5e44..91365dce5 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -1622,6 +1622,52 @@ overlay or with `kubectl scale`. ### Configuration +#### Database TLS + +Set `DB_SSLMODE` on every PHP role: API server, queue worker, bootstrap/migration +and scheduler. `DB_SSL_MODE` remains an accepted alias. PostgreSQL defaults to +`prefer`; MySQL/MariaDB retain their existing connection defaults when no mode +is set. These defaults do not guarantee an authenticated encrypted connection. + +| Mode | PostgreSQL | MySQL / MariaDB | +| --- | --- | --- | +| `disable` | No TLS. | No TLS; conflicting TLS options are refused. | +| `allow`, `prefer` | Opportunistic TLS, with no encryption/identity guarantee. | Refused; select an explicit supported mode. | +| `require` | Encryption required. | Encryption required with mysqlnd and OpenSSL. | +| `verify-ca` | Encryption and a trusted certificate chain required. | Refused because PDO mysqlnd cannot provide chain-only verification independently of hostname verification. | +| `verify-full` | Encryption, trusted chain and matching hostname required. | Encryption, trusted chain and matching hostname required. | + +For certificate verification, set `DB_SSLROOTCERT` to a readable CA bundle file +inside each container. `DB_SSL_ROOT_CERT` is an alias. The existing +`MYSQL_ATTR_SSL_CA` can supply the CA for an explicit MySQL/MariaDB +`verify-full` connection. Conflicting canonical/alias/URL settings refuse +startup rather than silently change the requested mode or CA. Required TLS +uses an explicit TCP hostname; Unix socket connections are refused. PHP +MySQL/MariaDB explicit TLS modes require PDO MySQL with mysqlnd and OpenSSL. + +```bash +DB_CONNECTION=pgsql +DB_HOST=database.example.com +DB_SSLMODE=verify-full +DB_SSLROOTCERT=/run/database-ca/ca.pem +``` + +Use the certificate's DNS hostname, not a substituted IP or unrelated alias. +With Helm, set `externalDatabase.sslMode: verify-full` and +`externalDatabase.tls.existingSecret` / `caKey`; the chart mounts the CA into +all four roles. Create that Secret before installing or upgrading because the +bootstrap hook runs before chart-managed ordinary resources. + +`/api/ready` reports `checks.database.tls` only after its connection succeeds. +`encrypted`, `ca_verified` and `hostname_verified` describe the guarantee +enforced by the selected driver mode. Opportunistic or unconfigured TLS is +reported as unknown, not verified. `require` guarantees encryption, not server +identity. Invalid configuration reports `database_tls_configuration_invalid`; +a failed required connection reports `database_tls_connection_failed` and +returns 503. The report covers PHP to its configured endpoint. If that endpoint +is a pooler, qualify and configure its separate database connection too; a +backend `pg_stat_ssl` result does not prove the client-to-pooler leg. + #### PostgreSQL connection pooling The PostgreSQL connection uses native bound parameters without session-scoped @@ -1653,7 +1699,8 @@ be unavailable. This remains a read probe: `write_capacity_verified=false`. Use an actual workflow canary to verify writes and the complete worker path. The runtime's lightweight bootstrap guard remains connection-only. -All operator-facing configuration is via `DW_*` environment variables. +Workflow runtime tuning uses `DW_*` environment variables. Database settings +such as the `DB_*` TLS inputs above retain Laravel's configuration boundary. `config/dw-contract.php` is the authoritative machine-checkable contract; CI (`tests/Unit/EnvContractTest.php`) diffs it against `.env.example`, `docker-compose.yml`, and `k8s/secret.yaml` so the three surfaces cannot diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index 69d84284d..1246d4a16 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -132,6 +132,38 @@ Driver, or any GitOps secret bridge that produces a Kubernetes `Secret`. | `ServiceAccount` (`*`) | Namespace-scoped SA for every workload. | `serviceAccount.create: false` | | `NetworkPolicy` (`*-server`) | Optional ingress allow-list to the API. | `networkPolicy.enabled: false` (default) | +## Database TLS + +For an authenticated encrypted database connection, pre-create a CA bundle +Secret in the release namespace and select `verify-full`: + +```yaml +externalDatabase: + sslMode: verify-full + tls: + existingSecret: workflow-database-ca + caKey: ca.crt +``` + +The chart sets `DB_SSLMODE` and mounts that Secret read-only at +`/etc/durable-workflow/database-tls/ca.pem`, referenced by `DB_SSLROOTCERT`. +The API server, worker, scheduler and bootstrap/migration Job all receive the +same trust bundle. The Secret must already exist when the pre-install or +pre-upgrade hook starts; the chart does not create it. The `database-tls` +volume name and `/etc/durable-workflow/database-tls` mount path are reserved +when this option is enabled. Existing application extra volumes are retained. + +Use the database certificate's DNS name as `externalDatabase.host`. `require` +requires encryption but does not guarantee the server's identity. PostgreSQL +also supports `verify-ca`; MySQL/MariaDB refuse that mode and require +`verify-full` for certificate verification. The complete mode and failure +contract is in [Database TLS](../../../docs/server-reference.md#database-tls). + +CA files update through the Secret volume rather than a `subPath` mount. +After rotating trust, restart all persistent PHP roles to replace existing +connections, then check `/api/ready` and run a workflow canary. Poolers need +independent TLS configuration on their database-facing leg. + ## Engine invariants the chart enforces These come from the engine, not the chart. Changing them silently breaks the diff --git a/k8s/helm/durable-workflow/ci/database-tls-values.yaml b/k8s/helm/durable-workflow/ci/database-tls-values.yaml new file mode 100644 index 000000000..b9ee42045 --- /dev/null +++ b/k8s/helm/durable-workflow/ci/database-tls-values.yaml @@ -0,0 +1,13 @@ +externalDatabase: + connection: pgsql + host: database.example.test + port: 5432 + sslMode: verify-full + existingSecret: database-credentials + tls: + existingSecret: database-ca + caKey: trusted.pem +externalRedis: + host: redis.example.test +auth: + existingSecret: application-credentials diff --git a/k8s/helm/durable-workflow/docs/UPGRADING.md b/k8s/helm/durable-workflow/docs/UPGRADING.md index d5cbac94d..73ecc0bcd 100644 --- a/k8s/helm/durable-workflow/docs/UPGRADING.md +++ b/k8s/helm/durable-workflow/docs/UPGRADING.md @@ -67,6 +67,26 @@ chart MINOR bump and an entry below. ## Per-version migration notes +### 0.2.0 + +Database TLS settings now affect the PHP connection. An old `sslMode` setting +that was previously ignored can cause a bootstrap or readiness refusal when +the database cannot meet that requirement. Check the complete mode contract in +[Database TLS](../README.md#database-tls) before applying the new chart. + +For `verify-full`, create a CA bundle Secret before the bootstrap hook runs and +set `externalDatabase.tls.existingSecret` and `caKey`. The same read-only CA +mount is present in the API server, worker, migration Job and scheduler. +`DB_SSLMODE` and `DB_SSLROOTCERT` are canonical; `DB_SSL_MODE` and +`DB_SSL_ROOT_CERT` aliases remain accepted. Conflicting aliases/URL settings, +missing verifying CAs and unsupported driver modes refuse rather than +downgrade. MySQL/MariaDB support `disable`, `require` and `verify-full`. + +The optional trust-bundle values are new. No database migration or package +dependency change is required by TLS enforcement. Verify readiness, certificate +identity and a workflow canary before returning traffic; preserve the earlier +stop-first upgrade boundaries below when crossing them from an older release. + ### 0.1.183 Server 2.9.7 uses Core's corrected caller-key update receipts. New update rows diff --git a/k8s/helm/durable-workflow/templates/_database-tls.tpl b/k8s/helm/durable-workflow/templates/_database-tls.tpl new file mode 100644 index 000000000..917e1c830 --- /dev/null +++ b/k8s/helm/durable-workflow/templates/_database-tls.tpl @@ -0,0 +1,39 @@ +{{- define "durable-workflow.databaseTlsCaPath" -}} +/etc/durable-workflow/database-tls/ca.pem +{{- end -}} + +{{- define "durable-workflow.validateDatabaseTlsVolumes" -}} +{{- if .Values.externalDatabase.tls.existingSecret -}} +{{- range $role := list .Values.server .Values.worker -}} +{{- range $volume := $role.extraVolumes -}} +{{- if eq $volume.name "database-tls" -}} +{{- fail "The database-tls volume name is reserved for database TLS." -}} +{{- end -}} +{{- end -}} +{{- range $mount := $role.extraVolumeMounts -}} +{{- if or (eq $mount.name "database-tls") (eq $mount.mountPath "/etc/durable-workflow/database-tls") -}} +{{- fail "The database-tls volume name and mount path are reserved for database TLS." -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "durable-workflow.databaseTlsVolumeMount" -}} +{{- if .Values.externalDatabase.tls.existingSecret -}} +- name: database-tls + mountPath: /etc/durable-workflow/database-tls + readOnly: true +{{- end -}} +{{- end -}} + +{{- define "durable-workflow.databaseTlsVolume" -}} +{{- if .Values.externalDatabase.tls.existingSecret -}} +- name: database-tls + secret: + secretName: {{ .Values.externalDatabase.tls.existingSecret | quote }} + items: + - key: {{ .Values.externalDatabase.tls.caKey | quote }} + path: ca.pem +{{- end -}} +{{- end -}} diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index ef7394b29..211b634d1 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -467,9 +467,13 @@ not depend on regular resources Helm or Argo create afterward. value: {{ . | quote }} {{- end }} {{- with .Values.externalDatabase.sslMode }} -- name: DB_SSL_MODE +- name: DB_SSLMODE value: {{ . | quote }} {{- end }} +{{- if .Values.externalDatabase.tls.existingSecret }} +- name: DB_SSLROOTCERT + value: {{ include "durable-workflow.databaseTlsCaPath" . | quote }} +{{- end }} - name: REDIS_HOST value: {{ .Values.externalRedis.host | quote }} - name: REDIS_PORT diff --git a/k8s/helm/durable-workflow/templates/configmap.yaml b/k8s/helm/durable-workflow/templates/configmap.yaml index 44c4be948..c3519743a 100644 --- a/k8s/helm/durable-workflow/templates/configmap.yaml +++ b/k8s/helm/durable-workflow/templates/configmap.yaml @@ -1,4 +1,5 @@ {{- include "durable-workflow.validateExternalPersistence" . }} +{{- include "durable-workflow.validateDatabaseTlsVolumes" . }} apiVersion: v1 kind: ConfigMap metadata: @@ -23,7 +24,10 @@ data: DB_SOCKET: {{ . | quote }} {{- end }} {{- with .Values.externalDatabase.sslMode }} - DB_SSL_MODE: {{ . | quote }} + DB_SSLMODE: {{ . | quote }} + {{- end }} + {{- if .Values.externalDatabase.tls.existingSecret }} + DB_SSLROOTCERT: {{ include "durable-workflow.databaseTlsCaPath" . | quote }} {{- end }} REDIS_HOST: {{ .Values.externalRedis.host | quote }} REDIS_PORT: {{ .Values.externalRedis.port | quote }} diff --git a/k8s/helm/durable-workflow/templates/migration-job.yaml b/k8s/helm/durable-workflow/templates/migration-job.yaml index d9e0c2dca..da2dfed09 100644 --- a/k8s/helm/durable-workflow/templates/migration-job.yaml +++ b/k8s/helm/durable-workflow/templates/migration-job.yaml @@ -59,6 +59,10 @@ spec: {{- include "durable-workflow.bootstrapEnv" . | nindent 12 }} resources: {{- toYaml .Values.bootstrap.resources | nindent 12 }} + {{- if .Values.externalDatabase.tls.existingSecret }} + volumeMounts: + {{- include "durable-workflow.databaseTlsVolumeMount" . | nindent 12 }} + {{- end }} {{- with .Values.bootstrap.containerSecurityContext }} securityContext: {{- toYaml . | nindent 12 }} @@ -66,6 +70,10 @@ spec: {{- with .Values.sidecars }} {{- toYaml . | nindent 8 }} {{- end }} + {{- if .Values.externalDatabase.tls.existingSecret }} + volumes: + {{- include "durable-workflow.databaseTlsVolume" . | nindent 8 }} + {{- end }} {{- with .Values.bootstrap.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/k8s/helm/durable-workflow/templates/scheduler-cronjob.yaml b/k8s/helm/durable-workflow/templates/scheduler-cronjob.yaml index f8aeeb8c0..e1812f405 100644 --- a/k8s/helm/durable-workflow/templates/scheduler-cronjob.yaml +++ b/k8s/helm/durable-workflow/templates/scheduler-cronjob.yaml @@ -81,10 +81,18 @@ spec: {{- include "durable-workflow.standardEnv" . | nindent 16 }} resources: {{- toYaml .Values.scheduler.resources | nindent 16 }} + {{- if .Values.externalDatabase.tls.existingSecret }} + volumeMounts: + {{- include "durable-workflow.databaseTlsVolumeMount" . | nindent 16 }} + {{- end }} {{- with .Values.scheduler.containerSecurityContext }} securityContext: {{- toYaml . | nindent 16 }} {{- end }} + {{- if .Values.externalDatabase.tls.existingSecret }} + volumes: + {{- include "durable-workflow.databaseTlsVolume" . | nindent 12 }} + {{- end }} {{- with .Values.scheduler.nodeSelector }} nodeSelector: {{- toYaml . | nindent 12 }} diff --git a/k8s/helm/durable-workflow/templates/server-deployment.yaml b/k8s/helm/durable-workflow/templates/server-deployment.yaml index 3299e4086..cc0f8a56d 100644 --- a/k8s/helm/durable-workflow/templates/server-deployment.yaml +++ b/k8s/helm/durable-workflow/templates/server-deployment.yaml @@ -87,16 +87,22 @@ spec: securityContext: {{- toYaml . | nindent 12 }} {{- end }} - {{- with .Values.server.extraVolumeMounts }} + {{- if or .Values.externalDatabase.tls.existingSecret .Values.server.extraVolumeMounts }} volumeMounts: + {{- include "durable-workflow.databaseTlsVolumeMount" . | nindent 12 }} + {{- with .Values.server.extraVolumeMounts }} {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.sidecars }} {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.server.extraVolumes }} + {{- if or .Values.externalDatabase.tls.existingSecret .Values.server.extraVolumes }} volumes: + {{- include "durable-workflow.databaseTlsVolume" . | nindent 8 }} + {{- with .Values.server.extraVolumes }} {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.server.nodeSelector }} nodeSelector: diff --git a/k8s/helm/durable-workflow/templates/worker-deployment.yaml b/k8s/helm/durable-workflow/templates/worker-deployment.yaml index 2b53e0346..94eadd15e 100644 --- a/k8s/helm/durable-workflow/templates/worker-deployment.yaml +++ b/k8s/helm/durable-workflow/templates/worker-deployment.yaml @@ -84,16 +84,22 @@ spec: securityContext: {{- toYaml . | nindent 12 }} {{- end }} - {{- with .Values.worker.extraVolumeMounts }} + {{- if or .Values.externalDatabase.tls.existingSecret .Values.worker.extraVolumeMounts }} volumeMounts: + {{- include "durable-workflow.databaseTlsVolumeMount" . | nindent 12 }} + {{- with .Values.worker.extraVolumeMounts }} {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.sidecars }} {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.worker.extraVolumes }} + {{- if or .Values.externalDatabase.tls.existingSecret .Values.worker.extraVolumes }} volumes: + {{- include "durable-workflow.databaseTlsVolume" . | nindent 8 }} + {{- with .Values.worker.extraVolumes }} {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.worker.nodeSelector }} nodeSelector: diff --git a/k8s/helm/durable-workflow/values.schema.json b/k8s/helm/durable-workflow/values.schema.json index d575fdef2..344ee8c1d 100644 --- a/k8s/helm/durable-workflow/values.schema.json +++ b/k8s/helm/durable-workflow/values.schema.json @@ -31,6 +31,13 @@ "database": { "type": "string", "minLength": 1 }, "socket": { "type": "string" }, "sslMode": { "type": "string" }, + "tls": { + "type": "object", + "properties": { + "existingSecret": { "type": "string" }, + "caKey": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$" } + } + }, "existingSecret": { "type": "string" }, "existingSecretUsernameKey": { "type": "string" }, "existingSecretPasswordKey": { "type": "string" }, diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index e4f51aa9a..94bfa1531 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -102,6 +102,10 @@ externalDatabase: # raw managed endpoint. socket: "" sslMode: "" + # Pre-create this CA bundle Secret before Helm's bootstrap hook runs. + tls: + existingSecret: "" + caKey: "ca.crt" # Credentials. existingSecret takes precedence over auth.{username,password}. existingSecret: "" existingSecretUsernameKey: "DB_USERNAME" diff --git a/scripts/ci/database-tls-compose.yaml b/scripts/ci/database-tls-compose.yaml new file mode 100644 index 000000000..d702cc4c6 --- /dev/null +++ b/scripts/ci/database-tls-compose.yaml @@ -0,0 +1,121 @@ +x-database: &database + user: "${TLS_TEST_UID}:${TLS_TEST_GID}" + cpus: 1 + mem_limit: 1g + memswap_limit: 1g + volumes: + - "${TLS_TEST_ROOT}/certs:/certs:ro" + +services: + tls-pg: + <<: *database + image: postgres:17.11 + environment: &postgres + PGDATA: /tmp/pgdata + POSTGRES_USER: dw_tls + POSTGRES_DB: dw_tls + POSTGRES_PASSWORD: bounded-fixture + tmpfs: ["/tmp/pgdata:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=384m"] + command: ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/certs/server.pem", "-c", "ssl_key_file=/certs/server.key"] + networks: + default: + aliases: [wrong-pg] + healthcheck: &pg-health + test: ["CMD", "pg_isready", "-U", "dw_tls", "-d", "dw_tls"] + interval: 2s + timeout: 2s + retries: 60 + + tls-mysql: + <<: *database + image: mysql:8.0.46 + environment: &mysql + MYSQL_ROOT_PASSWORD: bounded-root-fixture + MYSQL_DATABASE: dw_tls + MYSQL_USER: dw_tls + MYSQL_PASSWORD: bounded-fixture + tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] + command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server.pem", "--ssl-key=/certs/server.key", "--innodb-buffer-pool-size=64M", "--innodb-redo-log-capacity=64M", "--performance-schema=OFF"] + networks: + default: + aliases: [wrong-mysql] + healthcheck: &mysql-health + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_PASSWORD mysql --protocol=tcp -h127.0.0.1 -udw_tls dw_tls -e 'SELECT 1'"] + interval: 2s + timeout: 2s + retries: 90 + + tls-maria: + <<: *database + image: mariadb:10.11.19 + environment: + MARIADB_ROOT_PASSWORD: bounded-root-fixture + MARIADB_DATABASE: dw_tls + MARIADB_USER: dw_tls + MARIADB_PASSWORD: bounded-fixture + tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] + command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server.pem", "--ssl-key=/certs/server.key", "--innodb-buffer-pool-size=64M"] + networks: + default: + aliases: [wrong-maria] + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MARIADB_PASSWORD mariadb --protocol=tcp -h127.0.0.1 -udw_tls dw_tls -e 'SELECT 1'"] + interval: 2s + timeout: 2s + retries: 90 + + plain-pg: + <<: *database + profiles: [plaintext] + image: postgres:17.11 + environment: *postgres + tmpfs: ["/tmp/pgdata:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=384m"] + command: ["postgres", "-c", "ssl=off"] + healthcheck: *pg-health + + plain-mysql: + <<: *database + profiles: [plaintext] + image: mysql:8.0.46 + environment: *mysql + tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] + command: ["--skip-ssl", "--default-authentication-plugin=mysql_native_password", "--innodb-buffer-pool-size=64M", "--innodb-redo-log-capacity=64M", "--performance-schema=OFF"] + healthcheck: *mysql-health + + redis: + image: redis:7-alpine + user: "${TLS_TEST_UID}:${TLS_TEST_GID}" + cpus: 0.5 + mem_limit: 128m + memswap_limit: 128m + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 30 + + php: + image: "${TLS_TEST_IMAGE}" + entrypoint: [] + user: "${TLS_TEST_UID}:${TLS_TEST_GID}" + cpus: 1 + mem_limit: 768m + memswap_limit: 768m + working_dir: /work + volumes: + - "${TLS_TEST_ROOT}/app:/work" + - "${TLS_TEST_ROOT}/certs:/certs:ro" + environment: + HOME: /tmp + COMPOSER_HOME: /tmp/composer + APP_CONFIG_CACHE: /tmp/config.php + APP_ENV: local + APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + DB_DATABASE: dw_tls + DB_USERNAME: dw_tls + DB_PASSWORD: bounded-fixture + QUEUE_CONNECTION: database + CACHE_STORE: redis + REDIS_HOST: redis + DW_AUTH_TOKEN: database-tls-fixture-token diff --git a/scripts/ci/database-tls-probe.php b/scripts/ci/database-tls-probe.php index 4e04002e9..2337222c5 100644 --- a/scripts/ci/database-tls-probe.php +++ b/scripts/ci/database-tls-probe.php @@ -32,6 +32,10 @@ } } +$certificateVerified = $expected === 'verified'; +if ($expected === 'plaintext') { + $certificateVerified = null; +} $valid = $expected === 'refused' ? $response->getStatusCode() === 503 && ($database['status'] ?? '') === 'unavailable' @@ -39,8 +43,8 @@ : $response->getStatusCode() === 200 && ($database['status'] ?? '') === 'ok' && ($tls['encrypted'] ?? null) === ($expected !== 'plaintext') - && ($tls['ca_verified'] ?? null) === ($expected === 'plaintext' ? null : $expected === 'verified') - && ($tls['hostname_verified'] ?? null) === ($expected === 'plaintext' ? null : $expected === 'verified') + && ($tls['ca_verified'] ?? null) === $certificateVerified + && ($tls['hostname_verified'] ?? null) === $certificateVerified && ($transport['encrypted'] ?? null) === ($expected !== 'plaintext') && ($tls['scope'] ?? '') === 'php_to_configured_database_endpoint'; diff --git a/scripts/ci/qualify-database-tls.sh b/scripts/ci/qualify-database-tls.sh new file mode 100644 index 000000000..13efa555d --- /dev/null +++ b/scripts/ci/qualify-database-tls.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TLS_TEST_IMAGE:?Build the repository base image and set TLS_TEST_IMAGE.}" +TLS_TEST_PROJECT="${TLS_TEST_PROJECT:-dw-server-tls-$(date -u +%Y%m%dT%H%M%S)-$$}" +tls_parent="${TLS_TEST_ROOT:-${TMPDIR:-/tmp}}" +mkdir -p "$tls_parent" +TLS_TEST_ROOT="$(mktemp -d "$tls_parent/dw-server-tls.XXXXXX")" +TLS_TEST_UID="$(id -u)" +TLS_TEST_GID="$(id -g)" +export TLS_TEST_PROJECT TLS_TEST_ROOT TLS_TEST_IMAGE TLS_TEST_UID TLS_TEST_GID +tls_repository="$(cd -- "$(dirname -- "$0")/../.." && pwd)" +tls_compose="$tls_repository/scripts/ci/database-tls-compose.yaml" +mkdir -p "$TLS_TEST_ROOT/app" "$TLS_TEST_ROOT/certs" + +compose() { + docker compose --project-name "$TLS_TEST_PROJECT" --file "$tls_compose" "$@" +} +cleanup() { + local result=$? + trap - EXIT + compose --profile plaintext down --volumes --remove-orphans || result=1 + rm -rf -- "$TLS_TEST_ROOT" || result=1 + exit "$result" +} +trap cleanup EXIT + +# Copy committed source only; no local environment files or shared vendor state. +git -C "$tls_repository" archive HEAD | tar -xf - -C "$TLS_TEST_ROOT/app" +docker run --rm --name "$TLS_TEST_PROJECT-certificates" \ + --label "com.docker.compose.project=$TLS_TEST_PROJECT" \ + --user "$TLS_TEST_UID:$TLS_TEST_GID" --entrypoint sh \ + --cpus 1 --memory 512m --memory-swap 512m \ + -v "$TLS_TEST_ROOT/certs:/certs" -w /certs "$TLS_TEST_IMAGE" -c ' + set -eu + umask 077 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=fixture-ca -addext basicConstraints=critical,CA:TRUE -keyout ca.key -out ca.pem >/dev/null 2>&1 + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=wrong-ca -addext basicConstraints=critical,CA:TRUE -keyout wrong-ca.key -out wrong-ca.pem >/dev/null 2>&1 + openssl req -new -newkey rsa:2048 -nodes -subj /CN=tls-pg -keyout server.key -out server.csr >/dev/null 2>&1 + printf "subjectAltName=DNS:tls-pg,DNS:tls-mysql,DNS:tls-maria\nbasicConstraints=CA:FALSE\n" > server.ext + openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile server.ext -out server.pem >/dev/null 2>&1 + ' +compose run --rm --no-deps php composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist +compose up --wait --wait-timeout 240 tls-pg tls-mysql tls-maria redis + +probe() { + local driver="$1" host="$2" port="$3" mode="$4" ca="$5" expected="$6" + compose run --rm --no-deps \ + -e "DB_CONNECTION=$driver" -e "DB_HOST=$host" -e "DB_PORT=$port" \ + -e "DB_SSLMODE=$mode" -e "DB_SSLROOTCERT=$ca" \ + php php scripts/ci/database-tls-probe.php "$expected" +} +bootstrap() { + compose run --rm --no-deps \ + -e "DB_CONNECTION=$1" -e "DB_HOST=$2" -e "DB_PORT=$3" \ + -e "DB_SSLMODE=$4" -e "DB_SSLROOTCERT=$5" \ + php php artisan server:bootstrap --force --quiet +} + +for driver in pgsql mysql mariadb; do + case "$driver" in + pgsql) host=tls-pg; wrong=wrong-pg; port=5432 ;; + mysql) host=tls-mysql; wrong=wrong-mysql; port=3306 ;; + mariadb) host=tls-maria; wrong=wrong-maria; port=3306 ;; + esac + bootstrap "$driver" "$host" "$port" verify-full /certs/ca.pem + probe "$driver" "$host" "$port" verify-full /certs/ca.pem verified + probe "$driver" "$host" "$port" require '' encrypted + probe "$driver" "$host" "$port" verify-full /certs/wrong-ca.pem refused + probe "$driver" "$wrong" "$port" verify-full /certs/ca.pem refused +done +probe pgsql tls-pg 5432 verify-full '' refused +probe pgsql tls-pg 5432 verify-full /certs/missing.pem refused +compose stop tls-pg tls-mysql tls-maria +compose up --wait --wait-timeout 240 plain-pg plain-mysql +for driver in pgsql mysql; do + if [ "$driver" = pgsql ]; then host=plain-pg; port=5432; else host=plain-mysql; port=3306; fi + bootstrap "$driver" "$host" "$port" disable '' + probe "$driver" "$host" "$port" disable '' plaintext + probe "$driver" "$host" "$port" require '' refused +done diff --git a/scripts/ci/test_database_tls_chart.py b/scripts/ci/test_database_tls_chart.py new file mode 100644 index 000000000..e759d0a79 --- /dev/null +++ b/scripts/ci/test_database_tls_chart.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Check CA delivery to every role, including the pre-install bootstrap hook.""" + +from pathlib import Path +import subprocess +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +CHART = ROOT / "k8s/helm/durable-workflow" +CA_PATH = "/etc/durable-workflow/database-tls/ca.pem" + + +def render(*options: str) -> list[dict]: + output = subprocess.run( + ["helm", "template", "tls-check", str(CHART), "--namespace", "tls-check", + "--values", str(CHART / "ci/database-tls-values.yaml"), *options], + check=True, capture_output=True, text=True, + ) + return [item for item in yaml.safe_load_all(output.stdout) if item] + + +def pod_spec(resource: dict) -> dict | None: + kind = resource["kind"] + if kind in {"Deployment", "Job"}: + return resource["spec"]["template"]["spec"] + if kind == "CronJob": + return resource["spec"]["jobTemplate"]["spec"]["template"]["spec"] + return None + + +class DatabaseTlsChartTest(unittest.TestCase): + def test_ca_reaches_all_four_roles_in_hook_and_regular_bootstrap(self): + for hooks in ["true", "false"]: + with self.subTest(hooks=hooks): + resources = render("--set", f"bootstrap.useHelmHooks={hooks}") + config = next(item for item in resources if item["kind"] == "ConfigMap")["data"] + self.assertEqual(config["DB_SSLMODE"], "verify-full") + self.assertEqual(config["DB_SSLROOTCERT"], CA_PATH) + roles = set() + for resource in resources: + pod = pod_spec(resource) + if pod is None: + continue + container = pod["containers"][0] + roles.add(container["name"]) + volume = next(item for item in pod["volumes"] if item["name"] == "database-tls") + self.assertEqual(volume["secret"], { + "secretName": "database-ca", "items": [{"key": "trusted.pem", "path": "ca.pem"}], + }) + mount = next(item for item in container["volumeMounts"] if item["name"] == "database-tls") + self.assertTrue(mount["readOnly"]) + self.assertEqual(mount["mountPath"] + "/ca.pem", CA_PATH) + if container["name"] == "migrate" and hooks == "true": + environment = {item["name"]: item.get("value") for item in container["env"]} + self.assertEqual(environment["DB_SSLMODE"], "verify-full") + self.assertEqual(environment["DB_SSLROOTCERT"], CA_PATH) + self.assertEqual(roles, {"server", "worker", "migrate", "scheduler"}) + + def test_existing_volumes_and_mounts_survive_with_or_without_ca(self): + for ca in ["database-ca", ""]: + resources = render( + "--set", f"externalDatabase.tls.existingSecret={ca}", + "--set-json", 'server.extraVolumes=[{"name":"application-data","emptyDir":{}}]', + "--set", "server.extraVolumeMounts[0].name=application-data", + "--set", "server.extraVolumeMounts[0].mountPath=/application-data", + ) + server = next(item for item in resources if item["kind"] == "Deployment" and item["metadata"]["name"].endswith("-server")) + pod = pod_spec(server) + self.assertIsNotNone(pod) + self.assertEqual({item["name"] for item in pod["volumes"]}, {"application-data"} | ({"database-tls"} if ca else set())) + self.assertEqual({item["name"] for item in pod["containers"][0]["volumeMounts"]}, {item["name"] for item in pod["volumes"]}) + if not ca: + config = next(item for item in resources if item["kind"] == "ConfigMap")["data"] + self.assertNotIn("DB_SSLROOTCERT", config) + + def test_ca_volume_name_and_mount_path_cannot_be_shadowed(self): + for setting in [ + "server.extraVolumes[0].name=database-tls", + "worker.extraVolumeMounts[0].mountPath=/etc/durable-workflow/database-tls", + ]: + with self.subTest(setting=setting): + result = subprocess.run( + ["helm", "template", "tls-check", str(CHART), "--values", + str(CHART / "ci/database-tls-values.yaml"), "--set", setting], + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("reserved for database TLS", result.stderr) + + +if __name__ == "__main__": + unittest.main() From f880b864f46337d7b956912f39990cbcf900dff1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:44:04 +0000 Subject: [PATCH 4/8] Allow explicit database image selection for TLS qualification --- scripts/ci/database-tls-compose.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/ci/database-tls-compose.yaml b/scripts/ci/database-tls-compose.yaml index d702cc4c6..cc6c6f96d 100644 --- a/scripts/ci/database-tls-compose.yaml +++ b/scripts/ci/database-tls-compose.yaml @@ -9,7 +9,7 @@ x-database: &database services: tls-pg: <<: *database - image: postgres:17.11 + image: "${TLS_TEST_POSTGRES_IMAGE:-postgres:17.11}" environment: &postgres PGDATA: /tmp/pgdata POSTGRES_USER: dw_tls @@ -28,7 +28,7 @@ services: tls-mysql: <<: *database - image: mysql:8.0.46 + image: "${TLS_TEST_MYSQL_IMAGE:-mysql:8.0.46}" environment: &mysql MYSQL_ROOT_PASSWORD: bounded-root-fixture MYSQL_DATABASE: dw_tls @@ -47,7 +47,7 @@ services: tls-maria: <<: *database - image: mariadb:10.11.19 + image: "${TLS_TEST_MARIADB_IMAGE:-mariadb:10.11.19}" environment: MARIADB_ROOT_PASSWORD: bounded-root-fixture MARIADB_DATABASE: dw_tls @@ -67,7 +67,7 @@ services: plain-pg: <<: *database profiles: [plaintext] - image: postgres:17.11 + image: "${TLS_TEST_POSTGRES_IMAGE:-postgres:17.11}" environment: *postgres tmpfs: ["/tmp/pgdata:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=384m"] command: ["postgres", "-c", "ssl=off"] @@ -76,7 +76,7 @@ services: plain-mysql: <<: *database profiles: [plaintext] - image: mysql:8.0.46 + image: "${TLS_TEST_MYSQL_IMAGE:-mysql:8.0.46}" environment: *mysql tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] command: ["--skip-ssl", "--default-authentication-plugin=mysql_native_password", "--innodb-buffer-pool-size=64M", "--innodb-redo-log-capacity=64M", "--performance-schema=OFF"] From 51cda78ea2376f932e142294a957b6c4560d26f9 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:51:43 +0000 Subject: [PATCH 5/8] Prepare Server 2.9.10 and Helm 0.2.0 database TLS release --- composer.json | 2 +- composer.lock | 2 +- docker-compose.dedicated-matching.yml | 4 ++-- docker-compose.memo-rolling.yml | 4 ++-- docker-compose.published.yml | 4 ++-- docker-compose.small-cluster.yml | 2 +- docker-compose.yml | 8 ++++---- k8s/README.md | 10 +++++----- k8s/helm/durable-workflow/Chart.yaml | 12 +++++++----- k8s/helm/durable-workflow/README.md | 2 +- .../durable-workflow/ci/existing-secrets-values.yaml | 2 +- .../durable-workflow/ci/ingress-and-hpa-values.yaml | 2 +- .../durable-workflow/ci/inline-secrets-values.yaml | 2 +- k8s/helm/durable-workflow/templates/_helpers.tpl | 2 +- k8s/helm/durable-workflow/values.yaml | 2 +- k8s/helm/examples/values-dev.yaml | 2 +- .../examples/values-external-secrets-operator.yaml | 2 +- .../examples/values-production-existing-secrets.yaml | 2 +- k8s/migration-job.yaml | 2 +- k8s/scheduler-cronjob.yaml | 2 +- k8s/secret.yaml | 2 +- k8s/server-deployment.yaml | 2 +- k8s/worker-deployment.yaml | 2 +- resources/release/source-release.json | 4 ++-- scripts/k8s-kind-smoke.sh | 2 +- 25 files changed, 42 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index 2de1c9710..1f29b3de0 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.9.9" + "product-train": "2.9.10" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 6ffbeab65..a8901bfeb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1230e17c71101ee7340e52d0c1fe2e35", + "content-hash": "9e1f5e241b45408260ab2b0c53d6c282", "packages": [ { "name": "apache/avro", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 48c69e0ab..d329b6a48 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.10}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index 4c77c8946..387c12368 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.9.9} + APP_VERSION: ${APP_VERSION:-2.9.10} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.9.9} + APP_VERSION: ${APP_VERSION:-2.9.10} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index e5cc0b811..5d832509f 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.9}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.10}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.9}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.10}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index a6a242017..c1997446f 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.9.9} + APP_VERSION: ${APP_VERSION:-2.9.10} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index 95d538a38..3df7d39a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.9.9}" + APP_VERSION: "${APP_VERSION:-2.9.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.9.9}" + APP_VERSION: "${APP_VERSION:-2.9.10}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.9.9}" + APP_VERSION: "${APP_VERSION:-2.9.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -178,7 +178,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.9.9}" + APP_VERSION: "${APP_VERSION:-2.9.10}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/k8s/README.md b/k8s/README.md index e1a7dd001..abe55a1c2 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.9.9 +durableworkflow/server:2.9.10 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.9.9 + server=durableworkflow/server:2.9.10 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.9.9 + worker=durableworkflow/server:2.9.10 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.9.9 + scheduler=durableworkflow/server:2.9.10 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.9.9`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.9.10`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 321c99ce0..13945b7c2 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -4,12 +4,12 @@ description: Durable Workflow self-hosted server, worker, scheduler/maintenance type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules -# for this version live in docs/helm-upgrading.md alongside the chart. -version: 0.1.185 +# for this version live in docs/UPGRADING.md alongside the chart. +version: 0.2.0 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.9.9" +appVersion: "2.9.10" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,10 +30,12 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.9.9" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.9.10" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. artifacthub.io/changes: | - kind: fixed - description: Allow compatible workflow-code replacement with validated workers and preserve incompatible entry failures for supported same-run repair. + description: Enforce configured database TLS modes and refuse invalid trust, hostname or plaintext fallback. + - kind: added + description: Mount an existing database CA Secret read-only in server, worker, bootstrap and scheduler roles. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index d5afd68ab..70ebddcbe 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.9.9" + tag: "2.9.10" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 11f807c49..22754aa6e 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index 70d9fa26c..664c48438 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 8160b1c33..c282161e0 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index b4adebefe..aa042d4e3 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.9.9" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.9.10" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index 25dcc8f60..f24137413 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.9.9" + tag: "2.9.10" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index 871391cbe..58fd71cee 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 2bbfd25c3..0cc5aa01b 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index e1faeeff0..3dc6c7d63 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.9.9" + tag: "2.9.10" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index a514b2e47..94cbfc78d 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.9.9 + image: durableworkflow/server:2.9.10 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 6017ed7c3..2c04351c5 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.9.9 + image: durableworkflow/server:2.9.10 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 753b17c9e..d391e6973 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.9.9" + APP_VERSION: "2.9.10" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 15c71b386..4ee54a94d 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.9.9 + image: durableworkflow/server:2.9.10 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 910009def..7338cd5e8 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.9.9 + image: durableworkflow/server:2.9.10 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 234add484..368576096 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.9.9" + "version": "2.9.10" }, "helm_chart": { - "version": "0.1.185" + "version": "0.2.0" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index 9d49762dd..4ecafe50c 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.9.9" +manifest_image="durableworkflow/server:2.9.10" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" From 3fd9cfe4c25c3217e5a9ff1688b1041037271121 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 17:20:55 +0000 Subject: [PATCH 6/8] Scope TLS CI cleanup and generate isolated fixture credentials --- .github/workflows/helm-chart-checks.yml | 2 +- .github/workflows/phpunit-feature.yml | 13 ++++--- scripts/ci/cleanup-database-tls.sh | 11 ++++++ scripts/ci/database-tls-compose.yaml | 15 ++++---- scripts/ci/qualify-database-tls.sh | 27 +++++++++++---- scripts/ci/test_database_tls_cleanup.py | 46 +++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 scripts/ci/cleanup-database-tls.sh create mode 100644 scripts/ci/test_database_tls_cleanup.py diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index cec758e44..444321009 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -63,7 +63,7 @@ jobs: - name: Check database CA mounts and bootstrap configuration run: | - python3 -m pip install PyYAML==6.0.2 + python3 -m pip install --only-binary :all: PyYAML==6.0.2 python3 scripts/ci/test_database_tls_chart.py - name: Render every CI fixture diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index fc2a1ef9d..e80fa61af 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -303,14 +303,19 @@ jobs: docker image rm durable-workflow-server-context-candidate >/dev/null 2>&1 || true - name: Verify required database TLS and certificate refusals - run: timeout 600s bash scripts/ci/qualify-database-tls.sh + timeout-minutes: 10 + run: | + python3 scripts/ci/test_database_tls_cleanup.py + bash scripts/ci/qualify-database-tls.sh - name: Clean up database TLS fixtures if: ${{ always() }} run: | - docker ps -aq --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker rm -f - docker network ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker network rm - rm -rf -- "$TLS_TEST_ROOT" + docker rm -f "$TLS_TEST_PROJECT-certificates" >/dev/null 2>&1 || true + docker ps -aq --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true + docker volume ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true + bash scripts/ci/cleanup-database-tls.sh - name: Clean up test services if: always() diff --git a/scripts/ci/cleanup-database-tls.sh b/scripts/ci/cleanup-database-tls.sh new file mode 100644 index 000000000..710cef07d --- /dev/null +++ b/scripts/ci/cleanup-database-tls.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TLS_TEST_ROOT:?Set the owned TLS fixture parent.}" +: "${RUNNER_TEMP:?Set the runner temporary directory.}" +tls_directory="${TLS_TEST_ROOT#"$RUNNER_TEMP"/}" +if [[ "$TLS_TEST_ROOT" != "$RUNNER_TEMP"/dw-server-tls-* || ! "$tls_directory" =~ ^dw-server-tls-[a-zA-Z0-9_-]+$ ]]; then + echo 'Refusing to remove a TLS fixture directory outside its runner scope.' >&2 + exit 1 +fi +rm -rf -- "$TLS_TEST_ROOT" diff --git a/scripts/ci/database-tls-compose.yaml b/scripts/ci/database-tls-compose.yaml index cc6c6f96d..64e5f2bf8 100644 --- a/scripts/ci/database-tls-compose.yaml +++ b/scripts/ci/database-tls-compose.yaml @@ -3,6 +3,7 @@ x-database: &database cpus: 1 mem_limit: 1g memswap_limit: 1g + ports: !override [] volumes: - "${TLS_TEST_ROOT}/certs:/certs:ro" @@ -14,7 +15,7 @@ services: PGDATA: /tmp/pgdata POSTGRES_USER: dw_tls POSTGRES_DB: dw_tls - POSTGRES_PASSWORD: bounded-fixture + POSTGRES_PASSWORD: "${TLS_TEST_PASSWORD:?Generate the fixture password.}" tmpfs: ["/tmp/pgdata:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=384m"] command: ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/certs/server.pem", "-c", "ssl_key_file=/certs/server.key"] networks: @@ -30,10 +31,10 @@ services: <<: *database image: "${TLS_TEST_MYSQL_IMAGE:-mysql:8.0.46}" environment: &mysql - MYSQL_ROOT_PASSWORD: bounded-root-fixture + MYSQL_ROOT_PASSWORD: "${TLS_TEST_ROOT_PASSWORD:?Generate the fixture root password.}" MYSQL_DATABASE: dw_tls MYSQL_USER: dw_tls - MYSQL_PASSWORD: bounded-fixture + MYSQL_PASSWORD: "${TLS_TEST_PASSWORD:?Generate the fixture password.}" tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server.pem", "--ssl-key=/certs/server.key", "--innodb-buffer-pool-size=64M", "--innodb-redo-log-capacity=64M", "--performance-schema=OFF"] networks: @@ -49,10 +50,10 @@ services: <<: *database image: "${TLS_TEST_MARIADB_IMAGE:-mariadb:10.11.19}" environment: - MARIADB_ROOT_PASSWORD: bounded-root-fixture + MARIADB_ROOT_PASSWORD: "${TLS_TEST_ROOT_PASSWORD:?Generate the fixture root password.}" MARIADB_DATABASE: dw_tls MARIADB_USER: dw_tls - MARIADB_PASSWORD: bounded-fixture + MARIADB_PASSWORD: "${TLS_TEST_PASSWORD:?Generate the fixture password.}" tmpfs: ["/var/lib/mysql:rw,uid=${TLS_TEST_UID},gid=${TLS_TEST_GID},size=768m"] command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server.pem", "--ssl-key=/certs/server.key", "--innodb-buffer-pool-size=64M"] networks: @@ -83,6 +84,7 @@ services: healthcheck: *mysql-health redis: + ports: !override [] image: redis:7-alpine user: "${TLS_TEST_UID}:${TLS_TEST_GID}" cpus: 0.5 @@ -96,6 +98,7 @@ services: retries: 30 php: + ports: !override [] image: "${TLS_TEST_IMAGE}" entrypoint: [] user: "${TLS_TEST_UID}:${TLS_TEST_GID}" @@ -114,7 +117,7 @@ services: APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= DB_DATABASE: dw_tls DB_USERNAME: dw_tls - DB_PASSWORD: bounded-fixture + DB_PASSWORD: "${TLS_TEST_PASSWORD:?Generate the fixture password.}" QUEUE_CONNECTION: database CACHE_STORE: redis REDIS_HOST: redis diff --git a/scripts/ci/qualify-database-tls.sh b/scripts/ci/qualify-database-tls.sh index 13efa555d..9b2f9bb30 100644 --- a/scripts/ci/qualify-database-tls.sh +++ b/scripts/ci/qualify-database-tls.sh @@ -2,19 +2,32 @@ set -euo pipefail : "${TLS_TEST_IMAGE:?Build the repository base image and set TLS_TEST_IMAGE.}" -TLS_TEST_PROJECT="${TLS_TEST_PROJECT:-dw-server-tls-$(date -u +%Y%m%dT%H%M%S)-$$}" +: "${TLS_TEST_PROJECT:?Set the unique qualification project.}" tls_parent="${TLS_TEST_ROOT:-${TMPDIR:-/tmp}}" mkdir -p "$tls_parent" TLS_TEST_ROOT="$(mktemp -d "$tls_parent/dw-server-tls.XXXXXX")" TLS_TEST_UID="$(id -u)" TLS_TEST_GID="$(id -g)" -export TLS_TEST_PROJECT TLS_TEST_ROOT TLS_TEST_IMAGE TLS_TEST_UID TLS_TEST_GID +TLS_TEST_PASSWORD="$(openssl rand -hex 24)" +TLS_TEST_ROOT_PASSWORD="$(openssl rand -hex 24)" +export TLS_TEST_PROJECT TLS_TEST_ROOT TLS_TEST_IMAGE TLS_TEST_UID TLS_TEST_GID TLS_TEST_PASSWORD TLS_TEST_ROOT_PASSWORD tls_repository="$(cd -- "$(dirname -- "$0")/../.." && pwd)" tls_compose="$tls_repository/scripts/ci/database-tls-compose.yaml" mkdir -p "$TLS_TEST_ROOT/app" "$TLS_TEST_ROOT/certs" +cat > "$TLS_TEST_ROOT/no-ports.yaml" <<'YAML' +services: + tls-pg: &no-ports + ports: !override [] + tls-mysql: *no-ports + tls-maria: *no-ports + plain-pg: *no-ports + plain-mysql: *no-ports + redis: *no-ports + php: *no-ports +YAML compose() { - docker compose --project-name "$TLS_TEST_PROJECT" --file "$tls_compose" "$@" + docker compose --project-name "$TLS_TEST_PROJECT" --file "$tls_compose" --file "$TLS_TEST_ROOT/no-ports.yaml" "$@" } cleanup() { local result=$? @@ -51,9 +64,10 @@ probe() { php php scripts/ci/database-tls-probe.php "$expected" } bootstrap() { + local driver="$1" host="$2" port="$3" mode="$4" ca="$5" compose run --rm --no-deps \ - -e "DB_CONNECTION=$1" -e "DB_HOST=$2" -e "DB_PORT=$3" \ - -e "DB_SSLMODE=$4" -e "DB_SSLROOTCERT=$5" \ + -e "DB_CONNECTION=$driver" -e "DB_HOST=$host" -e "DB_PORT=$port" \ + -e "DB_SSLMODE=$mode" -e "DB_SSLROOTCERT=$ca" \ php php artisan server:bootstrap --force --quiet } @@ -62,6 +76,7 @@ for driver in pgsql mysql mariadb; do pgsql) host=tls-pg; wrong=wrong-pg; port=5432 ;; mysql) host=tls-mysql; wrong=wrong-mysql; port=3306 ;; mariadb) host=tls-maria; wrong=wrong-maria; port=3306 ;; + *) echo 'Unsupported TLS fixture driver.' >&2; exit 1 ;; esac bootstrap "$driver" "$host" "$port" verify-full /certs/ca.pem probe "$driver" "$host" "$port" verify-full /certs/ca.pem verified @@ -74,7 +89,7 @@ probe pgsql tls-pg 5432 verify-full /certs/missing.pem refused compose stop tls-pg tls-mysql tls-maria compose up --wait --wait-timeout 240 plain-pg plain-mysql for driver in pgsql mysql; do - if [ "$driver" = pgsql ]; then host=plain-pg; port=5432; else host=plain-mysql; port=3306; fi + if [[ "$driver" = pgsql ]]; then host=plain-pg; port=5432; else host=plain-mysql; port=3306; fi bootstrap "$driver" "$host" "$port" disable '' probe "$driver" "$host" "$port" disable '' plaintext probe "$driver" "$host" "$port" require '' refused diff --git a/scripts/ci/test_database_tls_cleanup.py b/scripts/ci/test_database_tls_cleanup.py new file mode 100644 index 000000000..2e35f81eb --- /dev/null +++ b/scripts/ci/test_database_tls_cleanup.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Exercise the always-step's owned-directory refusal before deleting fixtures.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class DatabaseTlsCleanupTest(unittest.TestCase): + def test_owned_partial_and_repeated_cleanup(self): + with tempfile.TemporaryDirectory() as runner: + owned = Path(runner) / "dw-server-tls-123-1-feature" + owned.mkdir() + (owned / "fixture").write_text("fixture") + for _ in range(2): + result = self.cleanup(runner, str(owned)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(owned.exists()) + + def test_foreign_and_parent_traversal_are_refused(self): + with tempfile.TemporaryDirectory() as runner: + foreign = Path(runner) / "foreign" + foreign.mkdir() + (foreign / "protected").write_text("keep") + prefix = Path(runner) / "dw-server-tls-123" + prefix.mkdir() + for target in [str(foreign), str(prefix / ".." / "foreign"), runner, "/"]: + result = self.cleanup(runner, target) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((foreign / "protected").read_text(), "keep") + + @staticmethod + def cleanup(runner, target): + return subprocess.run( + ["bash", str(Path(__file__).with_name("cleanup-database-tls.sh"))], + env={**os.environ, "RUNNER_TEMP": runner, "TLS_TEST_ROOT": target}, + capture_output=True, + text=True, + check=False, + ) + + +if __name__ == "__main__": + unittest.main() From 2ad981949d829370873722d1f7e73a3858ea8005 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 17:47:57 +0000 Subject: [PATCH 7/8] Evaluate TLS fixture runner context inside execution steps --- .github/workflows/phpunit-feature.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index e80fa61af..24b640501 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -112,11 +112,6 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 25 - env: - TLS_TEST_IMAGE: durable-workflow-server-phpunit-base - TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - steps: - name: Require central action policy preflight if: ${{ github.server_url == 'https://github.com' }} @@ -304,12 +299,19 @@ jobs: - name: Verify required database TLS and certificate refusals timeout-minutes: 10 + env: + TLS_TEST_IMAGE: durable-workflow-server-phpunit-base + TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: | python3 scripts/ci/test_database_tls_cleanup.py bash scripts/ci/qualify-database-tls.sh - name: Clean up database TLS fixtures if: ${{ always() }} + env: + TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: | docker rm -f "$TLS_TEST_PROJECT-certificates" >/dev/null 2>&1 || true docker ps -aq --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true From e272b6f7a7de461203ab8022a7aab6bec4da9a80 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 17:52:26 +0000 Subject: [PATCH 8/8] Retire completed feature services before starting TLS fixtures --- .github/workflows/phpunit-feature.yml | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 24b640501..1ce0da38c 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -297,6 +297,21 @@ jobs: docker volume rm -f "${DW_FIRST_RUN_SCOPE}-database" >/dev/null 2>&1 || true docker image rm durable-workflow-server-context-candidate >/dev/null 2>&1 || true + - name: Clean up test services + if: always() + run: | + for mode in session transaction0 transaction200; do + docker rm -f "dw-server-pooler-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-$mode" >/dev/null 2>&1 || true + done + docker rm -f \ + "dw-server-s3-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ + "dw-server-backup-mysql-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ + "dw-server-backup-pgsql-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ + >/dev/null 2>&1 || true + docker network rm \ + "dw-server-feature-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ + >/dev/null 2>&1 || true + - name: Verify required database TLS and certificate refusals timeout-minutes: 10 env: @@ -319,21 +334,6 @@ jobs: docker volume ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true bash scripts/ci/cleanup-database-tls.sh - - name: Clean up test services - if: always() - run: | - for mode in session transaction0 transaction200; do - docker rm -f "dw-server-pooler-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-$mode" >/dev/null 2>&1 || true - done - docker rm -f \ - "dw-server-s3-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ - "dw-server-backup-mysql-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ - "dw-server-backup-pgsql-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ - >/dev/null 2>&1 || true - docker network rm \ - "dw-server-feature-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \ - >/dev/null 2>&1 || true - qualification: name: Feature source qualification needs: [preflight, feature]