feat(release): ReleaseBridge — write staging request, read host result
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
05c98a7b9e
commit
54b41591b9
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The app side of the release bridge. requestStaging() writes a strictly-validated request to
|
||||
* run/release-request.json (a host watcher runs it); result() reads the host-written result for an
|
||||
* id this app issued. Validation here is the first gate — the host re-validates every value.
|
||||
* Reuses the same bind-mounted signal dir as the WireGuard/restart bridges.
|
||||
*/
|
||||
class ReleaseBridge
|
||||
{
|
||||
private function dir(): string
|
||||
{
|
||||
return storage_path('app/restart-signal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a staging-release request for a bare X.Y.Z target; returns the request id, or null when
|
||||
* the signal dir is not writable. Throws on a non-semver target (defence-in-depth).
|
||||
*/
|
||||
public function requestStaging(string $target): ?string
|
||||
{
|
||||
if (preg_match('/^\d+\.\d+\.\d+$/', $target) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid target');
|
||||
}
|
||||
|
||||
$id = Str::random(32);
|
||||
$payload = ['id' => $id, 'action' => 'stage', 'target' => $target, 'at' => time()];
|
||||
|
||||
@mkdir($this->dir(), 0775, true);
|
||||
$ok = @file_put_contents($this->dir().'/release-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $ok === false ? null : $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host result for an id THIS app issued. Null until the result exists. Never throws.
|
||||
*
|
||||
* @return array{ok:bool, tag:?string, message:string}|null
|
||||
*/
|
||||
public function result(string $id): ?array
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$file = $this->dir()."/release-result-{$id}.json";
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string) @file_get_contents($file), true);
|
||||
if (! is_array($data) || ($data['id'] ?? null) !== $id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => (bool) ($data['ok'] ?? false),
|
||||
'tag' => isset($data['tag']) && is_string($data['tag']) ? $data['tag'] : null,
|
||||
'message' => (string) ($data['message'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\ReleaseBridge;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReleaseBridgeTest extends TestCase
|
||||
{
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->dir = storage_path('app/restart-signal');
|
||||
@mkdir($this->dir, 0775, true);
|
||||
@unlink($this->dir.'/release-request.json');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->dir.'/release-request.json');
|
||||
foreach (glob($this->dir.'/release-result-*.json') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_request_writes_a_validated_stage_request_and_returns_an_id(): void
|
||||
{
|
||||
$id = app(ReleaseBridge::class)->requestStaging('0.10.0');
|
||||
|
||||
$this->assertNotNull($id);
|
||||
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{32}$/', $id);
|
||||
$payload = json_decode((string) file_get_contents($this->dir.'/release-request.json'), true);
|
||||
$this->assertSame('stage', $payload['action']);
|
||||
$this->assertSame('0.10.0', $payload['target']);
|
||||
$this->assertSame($id, $payload['id']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_a_non_semver_target(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(ReleaseBridge::class)->requestStaging('garbage');
|
||||
}
|
||||
|
||||
public function test_result_reads_the_host_result_for_the_issued_id(): void
|
||||
{
|
||||
$id = app(ReleaseBridge::class)->requestStaging('0.10.0');
|
||||
file_put_contents(
|
||||
$this->dir."/release-result-{$id}.json",
|
||||
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok'])
|
||||
);
|
||||
|
||||
$res = app(ReleaseBridge::class)->result($id);
|
||||
|
||||
$this->assertTrue($res['ok']);
|
||||
$this->assertSame('v0.10.0-beta1', $res['tag']);
|
||||
}
|
||||
|
||||
public function test_result_is_null_until_the_host_replies(): void
|
||||
{
|
||||
$id = app(ReleaseBridge::class)->requestStaging('0.10.0');
|
||||
$this->assertNull(app(ReleaseBridge::class)->result($id));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue