Der Kanal schreibt die Decke atomar und reicht sie durch
parent
4b2c2cd315
commit
367198459c
|
|
@ -46,6 +46,16 @@ final class UpdateChannel
|
|||
/** Written by the panel, consumed by the agent. */
|
||||
private const REQUEST = 'deploy/update-request.json';
|
||||
|
||||
/**
|
||||
* Wie weit dieser Server gehen darf.
|
||||
*
|
||||
* Keine Anfrage an den Agenten, sondern Zustand, den er liest: die Datei
|
||||
* bleibt liegen und gilt, bis sie jemand ändert. Deshalb steht sie neben
|
||||
* dem Postkasten und nicht darin — eine Anfrage wird verbraucht, eine
|
||||
* Decke nicht.
|
||||
*/
|
||||
private const CEILING = 'deploy/release-ceiling';
|
||||
|
||||
/**
|
||||
* Die Bitte, eine hängende Sperre zu lösen — in einer EIGENEN Datei.
|
||||
*
|
||||
|
|
@ -275,6 +285,11 @@ final class UpdateChannel
|
|||
// "three updates behind" from last week reads exactly like now.
|
||||
$behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null;
|
||||
|
||||
// Aus der Datei, nicht aus $status: siehe ceiling() weiter unten. Nur
|
||||
// einmal gelesen, weil state() bei jedem Seitenaufbau läuft und die
|
||||
// Datei sonst zweimal geöffnet würde, um zweimal dasselbe zu sagen.
|
||||
$ceiling = $this->ceiling();
|
||||
|
||||
return [
|
||||
'version' => $release->version,
|
||||
'commit' => $release->commit,
|
||||
|
|
@ -284,6 +299,22 @@ final class UpdateChannel
|
|||
// Null means "we do not know", which is not the same as "up to
|
||||
// date" and must not be shown as it.
|
||||
'behind' => $behind,
|
||||
// Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er
|
||||
// sieht die Tags und kann sagen, ob sie ins Leere zeigt.
|
||||
'ceiling' => $ceiling,
|
||||
'ceiling_error' => ($status['ceiling_error'] ?? '') !== ''
|
||||
? (string) $status['ceiling_error']
|
||||
: null,
|
||||
// Die Decke steht UNTER dem, was läuft. Kein Fehler, aber auch
|
||||
// nicht „aktuell": es steht eine Decke, sie ist nur überholt.
|
||||
// Hier gerechnet und nicht vom Agenten geholt — die Konsole hat
|
||||
// beide Zahlen bereits, und eine Frage weniger an den Wirt ist
|
||||
// eine Frage weniger, die veralten kann.
|
||||
'ceiling_passed' => $ceiling !== null
|
||||
&& version_compare(ltrim($ceiling, 'v'), $release->version, '<'),
|
||||
'releases' => is_array($status['releases'] ?? null)
|
||||
? array_values(array_filter($status['releases'], 'is_string'))
|
||||
: [],
|
||||
'available' => $behind !== null && $behind > 0,
|
||||
// The tag an update would install, e.g. "v1.1.0". An update is
|
||||
// always to a released version now — a commit landing on main is
|
||||
|
|
@ -559,6 +590,76 @@ final class UpdateChannel
|
|||
return $this->submit($by, self::KIND_CHECK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Server auf eine Version festnageln — oder die Decke abnehmen.
|
||||
*
|
||||
* `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, es
|
||||
* wurde nichts geschrieben.
|
||||
*
|
||||
* Die Decke wirkt NICHT dadurch, dass hier etwas ausgelöst wird. Der Agent
|
||||
* liest die Datei bei jedem Takt und rechnet `behind` dagegen; Knopf und
|
||||
* Wartungsfenster folgen daraus von selbst. Die `KIND_CHECK`-Anfrage
|
||||
* daneben ist reine Höflichkeit gegenüber der Anzeige: ohne sie stünde die
|
||||
* alte Zahl bis zum nächsten Takt, und ein Betreiber, der gerade
|
||||
* festgenagelt hat, läse sie als „hat nicht gewirkt".
|
||||
*/
|
||||
public function setCeiling(string $by, ?string $tag): bool
|
||||
{
|
||||
if ($tag === null) {
|
||||
File::delete(storage_path('app/'.self::CEILING));
|
||||
$this->requestCheck($by);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dieselbe Form, die der Agent prüft. Beide Seiten prüfen sie, weil
|
||||
// keine der anderen glauben kann: der Agent nicht der Datei, die
|
||||
// Konsole nicht der Eingabe.
|
||||
if (! preg_match('/^v\d+(\.\d+)*$/', $tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->writeAtomic(self::CEILING, $tag);
|
||||
$this->requestCheck($by);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gesetzte Decke, direkt aus der Datei.
|
||||
*
|
||||
* Nicht aus der Statusdatei des Agenten: die ist seine Rückmeldung und
|
||||
* hinkt bis zu einem Takt hinterher. Was die Konsole selbst geschrieben
|
||||
* hat, muss sie sofort anzeigen können.
|
||||
*/
|
||||
private function ceiling(): ?string
|
||||
{
|
||||
$path = storage_path('app/'.self::CEILING);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tag = trim((string) File::get($path));
|
||||
|
||||
return $tag === '' ? null : $tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreiben, das der Agent nie halb sieht.
|
||||
*
|
||||
* `write()` daneben benutzt File::put und taugt dafür nicht: der Agent
|
||||
* liest diese Datei bei jedem Takt, und eine halbe Zeile ist genau die
|
||||
* Art Decke, die er als kaputt ablehnen müsste.
|
||||
*/
|
||||
private function writeAtomic(string $relative, string $contents): void
|
||||
{
|
||||
$path = storage_path('app/'.$relative);
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put($path.'.tmp', $contents);
|
||||
File::move($path.'.tmp', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Proxy die Hostnamen aus der Konsole übernehmen lassen.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::delete(File::glob(storage_path('app/deploy/*')));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('writes and removes the ceiling', function () {
|
||||
$channel = app(UpdateChannel::class);
|
||||
$path = storage_path('app/deploy/release-ceiling');
|
||||
|
||||
expect($channel->setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue()
|
||||
->and(trim(File::get($path)))->toBe('v1.8.0');
|
||||
|
||||
expect($channel->setCeiling('chef@example.com', null))->toBeTrue()
|
||||
->and(File::exists($path))->toBeFalse();
|
||||
});
|
||||
|
||||
it('refuses a ceiling that is not a version', function () {
|
||||
// Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts
|
||||
// verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht
|
||||
// erst etwas hinlegen, das er ablehnen muss.
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse()
|
||||
->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves no temporary file behind', function () {
|
||||
// Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die
|
||||
// .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename.
|
||||
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('reports the ceiling from the file, not from the agent', function () {
|
||||
// Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt
|
||||
// zu warten, bis der Agent sie zurueckmeldet.
|
||||
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0');
|
||||
});
|
||||
|
||||
it('passes the ceiling complaint from the agent through', function () {
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'ceiling_error' => 'ceiling_missing',
|
||||
'releases' => ['v1.8.1', 'v1.8.0'],
|
||||
]));
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['ceiling_error'])->toBe('ceiling_missing')
|
||||
->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']);
|
||||
});
|
||||
|
||||
it('marks a ceiling that sits below what is deployed', function () {
|
||||
// Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon:
|
||||
// Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage
|
||||
// muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine
|
||||
// Decke, sie ist nur überholt.
|
||||
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
// `version` kommt aus Release::current(); in der Testumgebung ist das die
|
||||
// VERSION-Datei des Checkouts, die über 1.8.0 liegt.
|
||||
expect($state['ceiling_passed'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not mark a ceiling that is still ahead', function () {
|
||||
File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0');
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue