fix(engine-b): acceptance gate verifies real health, not just breadcrumbs
- Nextcloud: parse occ status JSON (installed + not in maintenance). - Admin: query occ user:info for the account. - Monitoring: MonitoringClient::isHealthy checks the provider, not the local row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
491800e09c
commit
9162446b1f
|
|
@ -4,17 +4,21 @@ namespace App\Provisioning\Steps\Customer;
|
|||
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Provisioning\StepResult;
|
||||
use App\Services\Monitoring\MonitoringClient;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use App\Services\Traefik\TraefikWriter;
|
||||
|
||||
/**
|
||||
* Gate: nothing grants the customer access until every check here passes.
|
||||
* Gate: nothing grants the customer access until every check here actually
|
||||
* passes — the cert is served, Nextcloud reports healthy, the admin account is
|
||||
* usable, a backup exists, and monitoring is green.
|
||||
*/
|
||||
class RunAcceptanceChecks extends CustomerStep
|
||||
{
|
||||
public function __construct(
|
||||
private ProxmoxClient $pve,
|
||||
private TraefikWriter $traefik,
|
||||
private MonitoringClient $monitoring,
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
|
|
@ -33,24 +37,37 @@ class RunAcceptanceChecks extends CustomerStep
|
|||
$node = (string) $run->context('node');
|
||||
$vmid = (int) $run->context('vmid');
|
||||
$fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone');
|
||||
$pve = $this->pve->forHost($instance->host);
|
||||
$occ = 'cd /opt/nextcloud && docker compose exec -T app php occ ';
|
||||
|
||||
// TLS + routing actually serving.
|
||||
if (! $instance->cert_ok || ! $this->traefik->certReachable($fqdn)) {
|
||||
return StepResult::fail('acceptance_failed:cert');
|
||||
}
|
||||
|
||||
$status = $this->pve->forHost($instance->host)
|
||||
->guestExec($node, $vmid, 'curl -sf http://localhost/status.php');
|
||||
if ((int) ($status['exitcode'] ?? 1) !== 0) {
|
||||
// Nextcloud installed, not in maintenance, DB reachable.
|
||||
$status = $pve->guestExec($node, $vmid, $occ.'status --output=json');
|
||||
$health = json_decode($status['out-data'] ?? '', true) ?: [];
|
||||
if ((int) ($status['exitcode'] ?? 1) !== 0
|
||||
|| ($health['installed'] ?? false) !== true
|
||||
|| ($health['maintenance'] ?? false) !== false) {
|
||||
return StepResult::fail('acceptance_failed:nextcloud');
|
||||
}
|
||||
|
||||
if (! $this->hasResource($run, 'nc_admin')) {
|
||||
// The admin account exists and is queryable.
|
||||
$admin = $pve->guestExec($node, $vmid, $occ.'user:info '.escapeshellarg((string) $instance->nc_admin_ref));
|
||||
if ((int) ($admin['exitcode'] ?? 1) !== 0) {
|
||||
return StepResult::fail('acceptance_failed:admin');
|
||||
}
|
||||
|
||||
// A backup job is registered.
|
||||
if (! $this->hasResource($run, 'backup_job_id')) {
|
||||
return StepResult::fail('acceptance_failed:backup');
|
||||
}
|
||||
if (! $this->hasResource($run, 'monitoring_target_id')) {
|
||||
|
||||
// Monitoring target exists and reports healthy.
|
||||
$target = $instance->monitoringTargets()->first();
|
||||
if ($target === null || ! $this->monitoring->isHealthy($target->external_id)) {
|
||||
return StepResult::fail('acceptance_failed:monitoring');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,15 @@ class FakeMonitoringClient implements MonitoringClient
|
|||
return $id;
|
||||
}
|
||||
|
||||
public bool $healthy = true;
|
||||
|
||||
public function deregisterTarget(string $externalId): void
|
||||
{
|
||||
unset($this->targets[$externalId]);
|
||||
}
|
||||
|
||||
public function isHealthy(string $externalId): bool
|
||||
{
|
||||
return $this->healthy;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,23 @@ class HttpMonitoringClient implements MonitoringClient
|
|||
->throw()->json('monitor.id');
|
||||
}
|
||||
|
||||
public function isHealthy(string $externalId): bool
|
||||
{
|
||||
$endpoint = (string) config('services.monitoring.url');
|
||||
if (blank($endpoint)) {
|
||||
return true; // no monitoring service configured
|
||||
}
|
||||
|
||||
try {
|
||||
$monitor = Http::withToken((string) config('services.monitoring.token'))
|
||||
->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []);
|
||||
|
||||
return ($monitor['status'] ?? '') === 'up';
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deregisterTarget(string $externalId): void
|
||||
{
|
||||
$endpoint = (string) config('services.monitoring.url');
|
||||
|
|
|
|||
|
|
@ -8,4 +8,7 @@ interface MonitoringClient
|
|||
public function registerTarget(string $name, string $url): string;
|
||||
|
||||
public function deregisterTarget(string $externalId): void;
|
||||
|
||||
/** Current health of a target as reported by the monitoring provider. */
|
||||
public function isHealthy(string $externalId): bool;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ it('provisions a paid order all the way to active (mocked)', function () {
|
|||
Queue::fake(); // drive the runner manually, one step per advance
|
||||
$s = fakeServices();
|
||||
$s['pve']->guestScript('status.php', 0, 'ok');
|
||||
$s['pve']->guestScript('hostname -I', 0, '10.20.0.9'); // deploy health + acceptance probe
|
||||
$s['pve']->guestScript('hostname -I', 0, '10.20.0.9');
|
||||
$s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); // deploy health + acceptance probe
|
||||
// Fake defaults: tasks stopped/OK, guest agent up, cert reachable.
|
||||
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
|
||||
|
|
@ -64,6 +65,7 @@ it('does not duplicate external resources when a step re-runs after a crash', fu
|
|||
$s = fakeServices();
|
||||
$s['pve']->guestScript('status.php', 0, 'ok');
|
||||
$s['pve']->guestScript('hostname -I', 0, '10.20.0.9');
|
||||
$s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}');
|
||||
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
|
||||
$order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']);
|
||||
|
|
|
|||
|
|
@ -313,22 +313,28 @@ it('registers a backup job and a monitoring target once each', function () {
|
|||
});
|
||||
|
||||
// 14. RunAcceptanceChecks
|
||||
it('passes acceptance only when everything is green', function () {
|
||||
fakeServices();
|
||||
it('passes acceptance only when everything is genuinely green', function () {
|
||||
$s = fakeServices();
|
||||
$s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); // healthy Nextcloud
|
||||
// occ user:info → default exit 0 (admin usable)
|
||||
['run' => $run, 'instance' => $instance, 'host' => $host] = reservedRun([], ['cert_ok' => true]);
|
||||
foreach (['nc_admin', 'backup_job_id', 'monitoring_target_id'] as $kind) {
|
||||
RunResource::create(['run_id' => $run->id, 'host_id' => $host->id, 'kind' => $kind, 'external_id' => 'x']);
|
||||
}
|
||||
RunResource::create(['run_id' => $run->id, 'host_id' => $host->id, 'kind' => 'backup_job_id', 'external_id' => 'b']);
|
||||
$instance->monitoringTargets()->create(['external_id' => 'mon-1', 'url' => 'https://x/status.php', 'status' => 'up']);
|
||||
|
||||
expect(app(RunAcceptanceChecks::class)->execute($run)->type)->toBe('advance');
|
||||
|
||||
// missing monitoring → fail
|
||||
RunResource::where('run_id', $run->id)->where('kind', 'monitoring_target_id')->delete();
|
||||
// monitoring unhealthy → fail
|
||||
$s['monitoring']->healthy = false;
|
||||
expect(app(RunAcceptanceChecks::class)->execute($run->fresh())->type)->toBe('fail');
|
||||
$s['monitoring']->healthy = true;
|
||||
|
||||
// Nextcloud not installed → fail
|
||||
$s['pve']->guestScript('occ status', 0, '{"installed":false}');
|
||||
expect(app(RunAcceptanceChecks::class)->execute($run->fresh())->type)->toBe('fail');
|
||||
|
||||
// failing cert → fail
|
||||
['run' => $run2, 'host' => $host2] = reservedRun([], ['cert_ok' => false]);
|
||||
expect(app(RunAcceptanceChecks::class)->execute($run2)->type)->toBe('fail');
|
||||
// cert not reachable → fail
|
||||
$s['traefik']->certReady = false;
|
||||
expect(app(RunAcceptanceChecks::class)->execute($run->fresh())->type)->toBe('fail');
|
||||
});
|
||||
|
||||
// 15. CompleteProvisioning
|
||||
|
|
|
|||
Loading…
Reference in New Issue