Esta é a causa real da queda do deploy de staging, reportada pelo erro
`sh: 2: Syntax error: "&&" unexpected`.
O `command` do serviço `migrate` estava escrito como block scalar
dobrado (`command: >`). Em YAML, o dobramento junta linhas com espaço
apenas quando elas têm a mesma indentação da primeira; toda linha mais
indentada preserva a quebra. As linhas `&& ...` estavam um espaço mais à
direita, então o valor final era:
sh -c "php artisan migrate --force --no-interaction
&& php artisan db:seed --class=ContentSeeder --force --no-interaction
&& php artisan media:generate-variants"
O `sh` recebe isso como duas linhas e aborta na segunda, antes de rodar
qualquer comando — daí o `sh: 2:` no erro. Como todos os outros serviços
dependem do migrate por `condition: service_completed_successfully`, a
stack inteira nunca sobe, e o Dokploy reporta `status=error` com
`errorMessage: null` porque nada da aplicação chegou a executar.
O formato exec-array de uma linha não tem essa ambiguidade. Ele já havia
sido adotado em 5949fad exatamente por isso, e o squash de reconciliação
58f24a6 o reverteu para o formato dobrado — o mesmo commit que também
apagou os oito baselines visuais restaurados no PR #29.
O `--force` inválido em `media:generate-variants`, corrigido no PR #32,
era um segundo defeito real na mesma linha, mas não era o que derrubava o
deploy: o `sh` morria antes de chegar lá.
Acrescenta guarda de regressão: nenhum `command` do compose de deploy
pode ser block scalar. Verificada nos dois sentidos — passa com o formato
atual e falha com a mensagem certa se o formato dobrado voltar.
Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
Co-authored-by: manoel.neto <manoel.neto@creditas.com>
111 lines
4.4 KiB
PHP
111 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Deployment;
|
|
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* The `migrate` one-shot service in docker-compose.deploy.yml chains artisan
|
|
* calls with `&&`, and every other service waits on it via
|
|
* `condition: service_completed_successfully`. An unknown option makes artisan
|
|
* exit non-zero, the one-shot fails, and web/queue/scheduler never start — the
|
|
* whole stack stays down with no application-level error to read.
|
|
*
|
|
* That is exactly how `media:generate-variants --force` took staging down: the
|
|
* command defines no options at all.
|
|
*/
|
|
class DeployComposeArtisanCommandsTest extends TestCase
|
|
{
|
|
/**
|
|
* A folded (`>`) or literal (`|`) block scalar preserves the newline before
|
|
* any line indented deeper than the first one. `sh -c` then receives a
|
|
* multi-line string and aborts with `sh: 2: Syntax error: "&&" unexpected`
|
|
* before running a single command — the `migrate` one-shot fails, and
|
|
* because every other service waits on
|
|
* `condition: service_completed_successfully`, the whole stack stays down.
|
|
*
|
|
* This has now broken staging twice: fixed once in 5949fad by switching to
|
|
* the exec-array form, then reintroduced by 58f24a6. Hence a guard.
|
|
*/
|
|
public function test_deploy_compose_never_uses_a_block_scalar_for_command(): void
|
|
{
|
|
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
|
|
|
|
preg_match_all('/^\s*command:\s*([>|][-+]?\d*)\s*$/m', $contents, $matches);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$matches[1],
|
|
'docker-compose.deploy.yml declares `command:` as a YAML block scalar ('.implode(', ', $matches[1]).'). '
|
|
.'Folding keeps the newline before every line indented deeper than the first, so `sh -c` gets a multi-line '
|
|
.'string and dies with `sh: 2: Syntax error: "&&" unexpected` before running anything. '
|
|
.'Use the single-line exec-array form: command: ["sh", "-c", "a && b && c"].'
|
|
);
|
|
}
|
|
|
|
public function test_every_artisan_call_in_the_deploy_compose_is_valid(): void
|
|
{
|
|
$invocations = $this->artisanInvocationsInDeployCompose();
|
|
|
|
$this->assertNotEmpty(
|
|
$invocations,
|
|
'Expected docker-compose.deploy.yml to invoke artisan; the parser found nothing, so this guard is not actually checking anything.'
|
|
);
|
|
|
|
$registered = Artisan::all();
|
|
|
|
foreach ($invocations as [$name, $options]) {
|
|
$this->assertArrayHasKey(
|
|
$name,
|
|
$registered,
|
|
"docker-compose.deploy.yml calls `php artisan {$name}`, which is not a registered command."
|
|
);
|
|
|
|
$command = $registered[$name];
|
|
|
|
// Options like --no-interaction and --env belong to the console
|
|
// application rather than the command, and only appear in the
|
|
// command's definition once the two are merged.
|
|
$command->mergeApplicationDefinition();
|
|
|
|
$definition = $command->getDefinition();
|
|
|
|
foreach ($options as $option) {
|
|
$this->assertTrue(
|
|
$definition->hasOption($option),
|
|
"docker-compose.deploy.yml calls `php artisan {$name} --{$option}`, but that command defines no `--{$option}` option. "
|
|
.'Artisan exits non-zero on an unknown option, which fails the migrate one-shot and prevents the whole stack from starting.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array{0: string, 1: list<string>}>
|
|
*/
|
|
private function artisanInvocationsInDeployCompose(): array
|
|
{
|
|
// Read the file as text rather than parsing YAML: the commands are
|
|
// folded block scalars, so a call can wrap across lines, and the repo
|
|
// ships no YAML parser. Collapsing whitespace makes the wrapping
|
|
// irrelevant and keeps the guard dependency-free.
|
|
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
|
|
$flattened = (string) preg_replace('/\s+/', ' ', $contents);
|
|
|
|
preg_match_all('/php artisan ([\w:.-]+)((?: --[\w-]+(?:=\S+)?)*)/', $flattened, $matches, PREG_SET_ORDER);
|
|
|
|
$invocations = [];
|
|
|
|
foreach ($matches as $match) {
|
|
preg_match_all('/--([\w-]+)/', $match[2], $optionMatches);
|
|
|
|
$invocations[] = [$match[1], $optionMatches[1]];
|
|
}
|
|
|
|
return $invocations;
|
|
}
|
|
}
|