Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot] committed 2026-10-28 13:28:51 +00:00
commit 9d0b6a5054
17 files changed
+415 -27

No files matched your search

+8 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class ValidateServer
@@ -38,7 +39,13 @@ class ValidateServer
'validation_logs' => null,
]);
if ($server->hetzner_server_id) {
$status = $server->refreshHetznerState();
try {
$status = $server->refreshHetznerState();
} catch (\Throwable $e) {
// The provider API can be down or the token revoked; the SSH checks below still decide.
Log::warning('Could not refresh the Hetzner server status before validation', ['server_id' => $server->id, 'error' => $e->getMessage()]);
$status = null;
}
if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) {
$this->error = $status === 'off'
? 'Hetzner server is off. Power it on before validating.'
+43 -1
View File
@@ -38,6 +38,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
@@ -919,7 +920,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables($buildEnvironment);
$buildEnvironment ??= $this->generate_buildtime_environment_variables();
$this->save_buildtime_environment_variables($buildEnvironment->merge($this->compose_build_interpolation_variables($composeFile, $buildEnvironment)));
if ($this->docker_compose_custom_build_command) {
// Auto-inject -f (compose file) and --env-file flags using helper function
@@ -985,6 +987,46 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* `docker compose build` interpolates the whole Compose file, so a required runtime-only variable
* (`${KEY:?}` in `environment:`) fails the build when the build-time .env file does not define it.
* This gives the build the runtime .env lines of the variables that the file uses outside its build
* sections. A variable that a build section or a build secret can read is left out, so a
* runtime-only value never becomes a build argument or part of an image.
*
* @return Collection<int, string>
*/
private function compose_build_interpolation_variables(mixed $composeFile, Collection $buildEnvironment): Collection
{
$compose = is_string($composeFile) ? parseDockerComposeYaml($composeFile) : convertToArray($composeFile);
$references = fn (array $values): Collection => collect(Arr::flatten($values))
->flatMap(fn (mixed $value): Collection => extractDockerComposeEnvironmentVariableReferences($value));
$readByBuilds = collect(data_get($compose, 'secrets', []))->pluck('environment')->filter();
foreach (data_get($compose, 'services', []) as $serviceName => $service) {
$build = data_get($service, 'build');
if ($build === null) {
continue;
}
$args = data_get($build, 'args', []);
$argNames = is_array($args) && array_is_list($args)
? array_map(fn (mixed $arg): string => explode('=', (string) $arg, 2)[0], $args)
: array_keys((array) $args);
$readByBuilds = $readByBuilds->merge($argNames)->merge($references((array) $build));
unset($compose['services'][$serviceName]['build']);
}
$buildKeys = $buildEnvironment->map(fn (string $line): string => explode('=', $line, 2)[0]);
$keys = $references($compose)->unique()->diff($readByBuilds)->diff($buildKeys);
if ($keys->isEmpty()) {
return collect();
}
return $this->generate_runtime_environment_variables()
->filter(fn (string $line): bool => $keys->contains(explode('=', $line, 2)[0]))
->values();
}
/**
* Parses the Compose file for this deployment and writes the volume warnings of the parser
* (for example an external volume that the resource does not use yet) to the deployment log.
+24 -14
View File
@@ -30,7 +30,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
* race. Such processes are not masters, so ControlPersist never reaps them
* and they hold an authenticated session until the container restarts.
*
* Every `ssh -fN` for a server carries the same ControlPath, so the socket
* Every master for a server carries the same ControlPath, so the socket
* file existing says nothing about which process owns it. Ownership is read
* from the socket itself (`ssh -O check` reports the master pid); every
* other process for that ControlPath is an orphan. When the socket file is
@@ -48,18 +48,9 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$socketOwners = [];
foreach ($this->listProcesses() as $process) {
// Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
continue;
}
// Only ever touch ssh processes pointing at Coolify's mux directory.
if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
continue;
}
$muxSocket = $pathMatch[1];
if ($process['etimes'] < $minAge
$muxSocket = $this->masterMuxSocket($process['args'], $muxDir);
if ($muxSocket === null
|| $process['etimes'] < $minAge
|| SshMultiplexingHelper::isMuxProcessRetiring($process['pid'], $muxSocket)) {
continue;
}
@@ -71,6 +62,24 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
}
}
/**
* ControlPath of a backgrounded ssh master in Coolify's mux directory, or
* null for any other process. ControlPersist retitles a master to
* `ssh: <ControlPath> [mux]`; masters started before the upgrade still run
* as `ssh -fN` or `ssh -fNM` with the ControlPath in their arguments.
*/
private function masterMuxSocket(string $args, string $muxDir): ?string
{
$socket = preg_quote($muxDir, '#').'/\S+';
if (preg_match('#^ssh: ('.$socket.') \[mux\]#', $args, $matches)
|| (preg_match('#(^|/)ssh -fN#', $args) && preg_match('#ControlPath=('.$socket.')#', $args, $matches))) {
return $matches[1];
}
return null;
}
/**
* Pid of the master that owns a mux socket. Returns null when the socket
* file does not exist, and false when it exists but its owner is unknown.
@@ -109,7 +118,8 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$sshPids = [];
foreach ($processes as $process) {
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
// A ControlPersist master is titled `ssh: <ControlPath> [mux]`.
if (preg_match('#(^|/)ssh[\s:]#', $process['args'])) {
$sshPids[$process['pid']] = true;
}
}
+8 -1
View File
@@ -8,6 +8,7 @@ use App\Events\ServerValidated;
use App\Jobs\CheckAndStartSentinelJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -117,7 +118,13 @@ class ValidateAndInstall extends Component
$this->authorize('update', $this->server);
Server::throttleValidation($this->server->team_id, $this->server->ip);
if ($this->server->hetzner_server_id) {
$status = $this->server->refreshHetznerState();
try {
$status = $this->server->refreshHetznerState();
} catch (\Throwable $e) {
// The provider API can be down or the token revoked; the SSH checks below still decide.
Log::warning('Could not refresh the Hetzner server status before validation', ['server_id' => $this->server->id, 'error' => $e->getMessage()]);
$status = null;
}
$this->server->refresh();
if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) {
+14
View File
@@ -61,6 +61,20 @@ trait ResolvesTeam
return $this->mcpError($request, "{$action} requires confirm=true.");
}
/**
* Same rule as the REST API (ApiSensitiveData): a root or read:sensitive token of a team admin or owner.
*/
protected function canReadSensitiveData(Request $request): bool
{
$user = $request->user();
$token = $user?->currentAccessToken();
if (! $token || ! ($token->can('root') || $token->can('read:sensitive'))) {
return false;
}
return $token->team_id !== null && $user->isAdminOfTeam((int) $token->team_id);
}
protected function resolveTeamId(Request $request): ?int
{
$user = $request->user();
+5 -2
View File
@@ -16,7 +16,7 @@ class GetApplication extends Tool
{
protected string $name = 'get_application';
protected string $description = 'Get full details for a single application by UUID.';
protected string $description = 'Get full details for a single application by UUID. Commands and custom configuration (build, start, deployment, and healthcheck commands, docker run options, nginx configuration) need read:sensitive.';
use BuildsResponse;
use ResolvesTeam;
@@ -47,8 +47,11 @@ class GetApplication extends Tool
$application->setRelations([]);
$application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
$visibleSensitiveKeys = $this->canReadSensitiveData($request) ? Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES : [];
$application->makeVisible($visibleSensitiveKeys);
return $this->mcpSuccess($request, $this->respond(
$this->scrubSensitive($application->toArray()),
$this->scrubSensitive($application->toArray(), $visibleSensitiveKeys),
$this->actionsForApplication($uuid, $application->status),
), ['resource_uuid' => $uuid]);
}
+5 -2
View File
@@ -15,7 +15,7 @@ class GetDatabase extends Tool
{
protected string $name = 'get_database';
protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse, sqlite.';
protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse, sqlite. Custom configuration (database config file, docker run options) needs read:sensitive.';
use BuildsResponse;
use ResolvesTeam;
@@ -45,8 +45,11 @@ class GetDatabase extends Tool
$database->setRelations([]);
$database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
$visibleSensitiveKeys = $this->canReadSensitiveData($request) ? $database::CONFIGURATION_ATTRIBUTES : [];
$database->makeVisible($visibleSensitiveKeys);
return $this->mcpSuccess($request, $this->respond(
$this->scrubSensitive($database->toArray()),
$this->scrubSensitive($database->toArray(), $visibleSensitiveKeys),
$this->actionsForDatabase($uuid, $database->status ?? null),
), ['resource_uuid' => $uuid]);
}
+10 -1
View File
@@ -13,6 +13,8 @@ use Throwable;
* Guzzle middleware that sends a request again once after a connection failure.
* cURL counts the DNS lookup in the connect timeout, and a lost DNS query waits 5 seconds before the resolver tries again.
* Only idempotent methods are retried, because a POST or PATCH can reach the server before the timeout.
* Guzzle also reports a timeout while waiting for the response as a connection failure; that request reached
* the server and already waited the full timeout, so it is not sent again.
*/
class RetryHttpConnectionFailures
{
@@ -22,7 +24,7 @@ class RetryHttpConnectionFailures
{
return function (RequestInterface $request, array $options) use ($handler): PromiseInterface {
return $this->send($handler, $request, $options)->otherwise(function (Throwable $exception) use ($handler, $request, $options): PromiseInterface {
if (! $exception instanceof ConnectException || ! in_array($request->getMethod(), self::RETRIED_METHODS, true)) {
if (! $exception instanceof ConnectException || $this->timedOutAfterSending($exception) || ! in_array($request->getMethod(), self::RETRIED_METHODS, true)) {
return Create::rejectionFor($exception);
}
@@ -33,6 +35,13 @@ class RetryHttpConnectionFailures
};
}
private function timedOutAfterSending(ConnectException $exception): bool
{
$context = $exception->getHandlerContext();
return ($context['errno'] ?? null) === CURLE_OPERATION_TIMEDOUT && ($context['request_size'] ?? 0) > 0;
}
/**
* The cURL handler rejects the promise on a connection failure, but other handlers (such as Http::fake()) throw.
*/
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Until v4.5.0, a HEALTHCHECK in the Dockerfile was used even when the Coolify healthcheck was enabled.
* Since v4.5.1 an enabled Coolify healthcheck replaces it. Existing applications with a Dockerfile
* HEALTHCHECK get the Coolify healthcheck disabled, so their deployments keep the Dockerfile healthcheck.
*/
public function up(): void
{
DB::table('applications')
->where('custom_healthcheck_found', true)
->where('health_check_enabled', true)
->update(['health_check_enabled' => false]);
}
public function down(): void
{
// This data migration cannot identify which applications had the Coolify healthcheck enabled.
}
};
@@ -26,9 +26,16 @@
@if ($customHealthcheckFound)
<div class="mb-4">
<x-callout type="warning" title="Custom healthcheck detected">
Enabling this configuration replaces the custom healthcheck currently defined by the application.
</x-callout>
@if ($healthCheckEnabled)
<x-callout type="warning" title="Dockerfile HEALTHCHECK replaced">
This healthcheck replaces the HEALTHCHECK from the Dockerfile. Disable it to use the
Dockerfile HEALTHCHECK again.
</x-callout>
@else
<x-callout type="info" title="Dockerfile HEALTHCHECK in use">
The application uses the HEALTHCHECK from its Dockerfile. Enabling this healthcheck replaces it.
</x-callout>
@endif
</div>
@endif
@@ -2076,3 +2076,70 @@ it('gives a preview from before v4.5.1 the production values it had', function (
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=') && str($env)->contains('production-secret')))->toBeTrue()
->and($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=') && str($env)->contains('coolify')))->toBeTrue();
});
it('lets the Compose build interpolate runtime-only variables without passing them to the build', function (int $pullRequestId) {
$compose = <<<'YAML'
services:
app:
build:
context: .
args:
MAPPED_ARG: '${RUNTIME_IN_BUILD_ARGS}'
LISTED_FROM_ENV: null
environment:
FERNET_KEY: '${FERNET_KEY:?}'
OPTIONAL_REQUIRED: '${OPTIONAL_REQUIRED?}'
BUILD_VALUE: '${BUILD_VALUE}'
worker:
build:
context: ./worker
args:
- LISTED_ARG
environment:
FERNET_KEY: '${FERNET_KEY:?}'
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => '[]',
]);
$application->settings()->update(['inject_build_args_to_dockerfile' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'FERNET_KEY', 'value' => 'runtime-fernet-key', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'OPTIONAL_REQUIRED', 'value' => '', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'RUNTIME_IN_BUILD_ARGS', 'value' => 'runtime-mapped-arg', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'LISTED_FROM_ENV', 'value' => 'runtime-null-arg', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'LISTED_ARG', 'value' => 'runtime-listed-arg', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'BUILD_VALUE', 'value' => 'build-value']);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'pull_request_id' => $pullRequestId,
'remote_secrets_cache' => [],
'force_rebuild' => false,
'docker_compose_location' => '/docker-compose.yaml',
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables');
$buildEnvironment = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
invokeDeploymentJobMethod($job, $reflection, 'build_docker_compose_images', parseDockerComposeYaml($compose), $buildEnvironment);
$buildEnvFile = $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH];
$buildCommand = collect($job->recordedCommands)->flatten()->first(fn (mixed $command): bool => is_string($command) && str_contains($command, ' build --pull'));
$buildArgs = readDeploymentJobProperty($job, $reflection, 'build_args')->implode(' ');
expect($buildEnvFile)->toContain('FERNET_KEY="runtime-fernet-key"')
->toContain('OPTIONAL_REQUIRED=""')
->toContain('BUILD_VALUE=')
->not->toContain('runtime-mapped-arg')
->not->toContain('runtime-null-arg')
->not->toContain('runtime-listed-arg')
->and($buildCommand)->toContain('--env-file '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH)
->and($buildArgs)->toContain('BUILD_VALUE')
->not->toContain('FERNET_KEY')
->not->toContain('runtime-');
})->with([
'production' => 0,
'preview' => 42,
]);
@@ -10,6 +10,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -84,3 +85,19 @@ it('blocks validation modal connection checks when the Hetzner server is off', f
'validation_logs' => 'Hetzner server is off. Power it on before validating.',
]);
});
it('validates over SSH when the Hetzner API call fails', function () {
Http::fake(['https://api.hetzner.cloud/v1/servers/456' => Http::response(['error' => ['code' => 'unauthorized']], 401)]);
$this->server->update(['ip' => '203.0.113.10']);
Process::fake();
Livewire::test(ValidateAndInstall::class, ['server' => $this->server])
->call('validateConnection')
->assertSet('error', null)
->assertSet('uptime', true)
->assertDispatched('validateOS');
Process::fake(['*' => Process::result(errorOutput: 'ssh: connect to host 203.0.113.10 port 22: Connection refused', exitCode: 255)]);
expect(fn () => ValidateServer::run($this->server))->toThrow(Exception::class, 'Connection refused');
});
+17
View File
@@ -38,3 +38,20 @@ test('a post request is not retried because the server can have received it', fu
expect(fn () => Http::post('https://api.example.com/items'))->toThrow(ConnectionException::class)
->and($this->attempts)->toBe(1);
});
test('a read request is not sent again after it timed out waiting for the response', function () {
$server = stream_socket_server('tcp://127.0.0.1:0');
$address = stream_socket_get_name($server, false);
expect(fn () => Http::timeout(1)->get("http://{$address}/slow"))
->toThrow(ConnectionException::class, 'cURL error 28');
stream_set_blocking($server, false);
$connections = 0;
while (@stream_socket_accept($server, 0)) {
$connections++;
}
fclose($server);
expect($connections)->toBe(1);
});
@@ -0,0 +1,69 @@
<?php
use App\Livewire\Project\Shared\HealthChecks;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
$team = Team::factory()->create();
$this->healthcheckMigrationOwner = User::factory()->create();
$team->members()->attach($this->healthcheckMigrationOwner->id, ['role' => 'owner']);
$project = Project::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$this->healthcheckMigrationEnvironment = $project->environments()->firstOrFail();
$this->healthcheckMigrationDestination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
});
function keepDockerfileHealthcheckApplication(bool $healthCheckEnabled, bool $customHealthcheckFound): Application
{
return Application::factory()->create([
'environment_id' => test()->healthcheckMigrationEnvironment->id,
'destination_id' => test()->healthcheckMigrationDestination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'health_check_enabled' => $healthCheckEnabled,
'custom_healthcheck_found' => $customHealthcheckFound,
])->fresh();
}
test('disables the Coolify healthcheck only for applications with a Dockerfile HEALTHCHECK', function () {
$dockerfileAndCoolify = keepDockerfileHealthcheckApplication(healthCheckEnabled: true, customHealthcheckFound: true);
$dockerfileOnly = keepDockerfileHealthcheckApplication(healthCheckEnabled: false, customHealthcheckFound: true);
$coolifyOnly = keepDockerfileHealthcheckApplication(healthCheckEnabled: true, customHealthcheckFound: false);
$none = keepDockerfileHealthcheckApplication(healthCheckEnabled: false, customHealthcheckFound: false);
(require database_path('migrations/2026_10_28_124421_keep_dockerfile_healthcheck_for_existing_applications.php'))->up();
expect($dockerfileAndCoolify->fresh()->health_check_enabled)->toBeFalse()
->and($dockerfileAndCoolify->fresh()->custom_healthcheck_found)->toBeTrue()
->and($dockerfileOnly->fresh()->health_check_enabled)->toBeFalse()
->and($coolifyOnly->fresh()->health_check_enabled)->toBeTrue()
->and($none->fresh()->health_check_enabled)->toBeFalse();
});
test('the healthcheck page says whether the Dockerfile HEALTHCHECK is in use or replaced', function () {
$this->actingAs($this->healthcheckMigrationOwner);
Livewire::test(HealthChecks::class, ['resource' => keepDockerfileHealthcheckApplication(healthCheckEnabled: false, customHealthcheckFound: true)])
->assertSee('Dockerfile HEALTHCHECK in use')
->assertDontSee('Dockerfile HEALTHCHECK replaced');
Livewire::test(HealthChecks::class, ['resource' => keepDockerfileHealthcheckApplication(healthCheckEnabled: true, customHealthcheckFound: true)])
->assertSee('Dockerfile HEALTHCHECK replaced')
->assertDontSee('Dockerfile HEALTHCHECK in use');
Livewire::test(HealthChecks::class, ['resource' => keepDockerfileHealthcheckApplication(healthCheckEnabled: true, customHealthcheckFound: false)])
->assertDontSee('Dockerfile HEALTHCHECK');
});
+60
View File
@@ -644,6 +644,66 @@ test('get_application omits free-form configuration that can contain secrets', f
->and($raw)->not->toContain('custom_nginx_configuration');
});
function mcpReadCallWithAbilities(array $abilities, string $name, array $arguments = [])
{
auth()->forgetGuards();
return test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.test()->user->createToken('mcp-abilities', $abilities)->plainTextToken,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => ['name' => $name, 'arguments' => (object) $arguments],
]);
}
test('get_application returns commands and configuration to a read:sensitive or root token of an owner', function (array $abilities) {
$this->application->update([
'build_command' => 'npm run build',
'post_deployment_command' => 'php artisan migrate',
'custom_docker_run_options' => '--cap-add SYS_ADMIN',
]);
$response = mcpReadCallWithAbilities($abilities, 'get_application', ['uuid' => $this->application->uuid]);
$response->assertOk();
expect(mcpReadJson($response)['data'])
->toMatchArray([
'build_command' => 'npm run build',
'post_deployment_command' => 'php artisan migrate',
'custom_docker_run_options' => '--cap-add SYS_ADMIN',
])
->not->toHaveKey('git_full_url');
})->with([
'read:sensitive' => [['read', 'read:sensitive']],
'root' => [['root']],
]);
test('get_database returns custom configuration to a read:sensitive token of an owner', function () {
$database = StandalonePostgresql::create([
'name' => 'Configured database',
'postgres_password' => 'database-password',
'postgres_conf' => 'max_connections = 200',
'custom_docker_run_options' => '--shm-size 1g',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = mcpSensitiveReadCall('get_database', ['uuid' => $database->uuid]);
$response->assertOk();
expect(mcpReadJson($response)['data'])
->toMatchArray([
'postgres_conf' => 'max_connections = 200',
'custom_docker_run_options' => '--shm-size 1g',
])
->not->toHaveKey('postgres_password');
});
test('get_database omits configuration blobs that can contain secrets', function () {
$database = StandalonePostgresql::create([
'name' => 'Secret-bearing config',
+32 -1
View File
@@ -409,6 +409,36 @@ it('kills only old orphaned ssh masters whose control socket no longer exists',
File::delete($liveSocket);
});
it('kills a ControlPersist master whose control socket was removed', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
File::ensureDirectoryExists($muxDir);
$liveSocket = $muxDir.'/mux_live_'.uniqid();
$removedSocket = $muxDir.'/mux_removed_'.uniqid();
File::put($liveSocket, 'x');
// ControlPersist sets the title of a backgrounded master to `ssh: <ControlPath> [mux]`.
Process::fake([
'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n".
"222 1 5000 ssh: {$removedSocket} [mux]\n".
"333 1 5000 ssh: /tmp/other/mux_removed [mux]\n"),
'*ssh -O check*' => Process::result(errorOutput: 'Master running (pid=111)'),
'kill*' => Process::result(exitCode: 0),
]);
$job = new CleanupStaleMultiplexedConnections;
$method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses');
$method->setAccessible(true);
$method->invoke($job);
Process::assertRan(fn ($process) => $process->command === 'kill -9 '.escapeshellarg('222'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111'));
Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333'));
File::delete($liveSocket);
});
it('kills ssh -fN processes that share a live ControlPath but do not own it', function () {
config(['constants.ssh.mux_orphan_reap_enabled' => true]);
$muxDir = storage_path('app/ssh/mux');
@@ -599,9 +629,10 @@ it('kills only old cloudflared proxies whose stdin pipe no live ssh holds', func
$orphanPid = proc_get_status($orphan)['pid'];
$tunnelSshPid = proc_get_status($tunnelSsh)['pid'];
$sshPid = getmypid();
$muxSocket = storage_path('app/ssh/mux/mux_tunnel');
Process::fake([
'ps*' => Process::result(output: "{$sshPid} 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4\n".
'ps*' => Process::result(output: "{$sshPid} 1 5000 ssh: {$muxSocket} [mux]\n".
"200 {$sshPid} 5000 cloudflared access ssh --hostname host.example.com\n".
"{$reparentedPid} 1 5000 cloudflared access ssh --hostname host.example.com\n".
"{$orphanPid} 1 5000 cloudflared access ssh --hostname host.example.com\n".
+1 -1
View File
@@ -7,7 +7,7 @@
"version": "4.6-rc.1"
},
"helper": {
"version": "1.0.18"
"version": "1.0.19"
},
"sentinel": {
"version": "1.1.3"