Files
amare/tests/Feature/Deployment/DeployComposeArtisanCommandsTest.php
Manoel Freitas c84c347dfd fix(deploy): voltar command do migrate para exec-array de uma linha (#38)
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>
2026-08-10 10:16:33 -03:00

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;
}
}