fix: resolve Docker validation race conditions and sudo prefix bug

- Fix sudo prefix bug: Use word boundary matching to prevent 'do' keyword from matching 'docker' commands
- Add ensureProxyNetworksExist() helper to create networks before docker compose up
- Ensure networks exist synchronously before dispatching async proxy startup to prevent race conditions
- Update comprehensive unit tests for sudo parsing (50 tests passing)

This resolves issues where Docker commands failed to execute with sudo on non-root servers and where proxy networks were not created before the proxy container started.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-11-27 09:04:42 +01:00
parent e5c7459284
commit 246e3cd8a2
6 changed files with 191 additions and 32 deletions

View file

@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
' done', ' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'", " echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi', 'fi',
]);
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
$commands = $commands->merge(ensureProxyNetworksExist($server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'", "echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans', 'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'", "echo 'Successfully started coolify-proxy.'",

View file

@ -168,6 +168,9 @@ public function handle(): void
if (! $this->server->isBuildServer()) { if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true); $proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) { if ($proxyShouldRun) {
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server); StartProxy::dispatch($this->server);
} }
} }

View file

@ -206,6 +206,9 @@ public function validateDockerVersion()
if (! $proxyShouldRun) { if (! $proxyShouldRun) {
return; return;
} }
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server); StartProxy::dispatch($this->server);
} else { } else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');

View file

@ -108,6 +108,37 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten(); return $commands->flatten();
} }
/**
* Ensures all required networks exist before docker compose up.
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
* @param Server $server The server to ensure networks on
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
*/
function ensureProxyNetworksExist(Server $server)
{
['allNetworks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
];
});
} else {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
];
});
}
return $commands->flatten();
}
function extractCustomProxyCommands(Server $server, string $existing_config): array function extractCustomProxyCommands(Server $server, string $existing_config): array
{ {
$custom_commands = []; $custom_commands = [];

View file

@ -23,8 +23,11 @@ function shouldChangeOwnership(string $path): bool
function parseCommandsByLineForSudo(Collection $commands, Server $server): array function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{ {
$commands = $commands->map(function ($line) { $commands = $commands->map(function ($line) {
if ( $trimmedLine = trim($line);
! str(trim($line))->startsWith([
// All bash keywords that should not receive sudo prefix
// Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find')
$bashKeywords = [
'cd', 'cd',
'command', 'command',
'echo', 'echo',
@ -32,7 +35,6 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
'if', 'if',
'fi', 'fi',
'for', 'for',
'do',
'done', 'done',
'while', 'while',
'until', 'until',
@ -44,17 +46,28 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
'elif', 'elif',
'break', 'break',
'continue', 'continue',
'#', 'do',
]) ];
) {
return "sudo $line"; // Special case: comments (no collision risk with '#')
if (str_starts_with($trimmedLine, '#')) {
return $line;
} }
if (str(trim($line))->startsWith('if')) { // Check all keywords with word boundary matching
return str_replace('if', 'if sudo', $line); // Match keyword followed by space, semicolon, or end of line
foreach ($bashKeywords as $keyword) {
if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
// Special handling for 'if' - insert sudo after 'if '
if ($keyword === 'if') {
return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
} }
return $line; return $line;
}
}
return "sudo $line";
}); });
$commands = $commands->map(function ($line) use ($server) { $commands = $commands->map(function ($line) use ($server) {

View file

@ -272,11 +272,9 @@
$result = parseCommandsByLineForSudo($commands, $this->server); $result = parseCommandsByLineForSudo($commands, $this->server);
// Note: 'docker' starts with 'do' which is an excluded keyword, so it doesn't get sudo prefix // docker commands now correctly get sudo prefix (word boundary fix for 'do' keyword)
// This is a known limitation of the startsWith approach. Docker commands still work because
// they typically appear in more complex command sequences or are handled separately.
// The || operator adds sudo to what follows, and subshell adds sudo inside $() // The || operator adds sudo to what follows, and subshell adds sudo inside $()
expect($result[0])->toBe('docker ps || sudo echo $(sudo date)'); expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)');
}); });
test('handles whitespace-only commands gracefully', function () { test('handles whitespace-only commands gracefully', function () {
@ -516,3 +514,110 @@
expect($result[0])->not->toContain('sudo select'); expect($result[0])->not->toContain('sudo select');
expect($result[2])->toBe(' break'); expect($result[2])->toBe(' break');
}); });
// Tests for word boundary matching - ensuring commands are not confused with bash keywords
test('adds sudo for ifconfig command (not confused with if keyword)', function () {
$commands = collect(['ifconfig eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifconfig eth0');
expect($result[0])->not->toContain('if sudo');
});
test('adds sudo for ifup command (not confused with if keyword)', function () {
$commands = collect(['ifup eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifup eth0');
});
test('adds sudo for ifdown command (not confused with if keyword)', function () {
$commands = collect(['ifdown eth0']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo ifdown eth0');
});
test('adds sudo for find command (not confused with fi keyword)', function () {
$commands = collect(['find /var -name "*.log"']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo find /var -name "*.log"');
});
test('adds sudo for file command (not confused with fi keyword)', function () {
$commands = collect(['file /tmp/test']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo file /tmp/test');
});
test('adds sudo for finger command (not confused with fi keyword)', function () {
$commands = collect(['finger user']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo finger user');
});
test('adds sudo for docker command (not confused with do keyword)', function () {
$commands = collect(['docker ps']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo docker ps');
});
test('adds sudo for fortune command (not confused with for keyword)', function () {
$commands = collect(['fortune']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo fortune');
});
test('adds sudo for formail command (not confused with for keyword)', function () {
$commands = collect(['formail -s procmail']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('sudo formail -s procmail');
});
test('if keyword with word boundary gets sudo inserted correctly', function () {
$commands = collect(['if [ -f /tmp/test ]; then']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('if sudo [ -f /tmp/test ]; then');
});
test('fi keyword with word boundary is not given sudo', function () {
$commands = collect(['fi']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('fi');
});
test('for keyword with word boundary is not given sudo', function () {
$commands = collect(['for i in 1 2 3; do']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('for i in 1 2 3; do');
});
test('do keyword with word boundary is not given sudo', function () {
$commands = collect(['do']);
$result = parseCommandsByLineForSudo($commands, $this->server);
expect($result[0])->toBe('do');
});