Harden API auto deploy gate
This commit is contained in:
@@ -121,6 +121,16 @@ class coolify_api_client
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listApplicationEnvs(string $uuid): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
|
||||||
|
}
|
||||||
|
|
||||||
private static function bulkEnvData(array $env): array
|
private static function bulkEnvData(array $env): array
|
||||||
{
|
{
|
||||||
$data = [];
|
$data = [];
|
||||||
|
|||||||
@@ -2092,11 +2092,7 @@ class release_manager
|
|||||||
|
|
||||||
$eventId = (int)$event['id'];
|
$eventId = (int)$event['id'];
|
||||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||||
return [
|
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
|
||||||
'step_status' => 'passed',
|
|
||||||
'message' => 'Automatic container update is already being processed for this commit.',
|
|
||||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2178,6 +2174,67 @@ class release_manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
|
||||||
|
{
|
||||||
|
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
|
||||||
|
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
|
||||||
|
$deadline = time() + $timeout;
|
||||||
|
$attempts = 0;
|
||||||
|
$lastStatus = 'unknown';
|
||||||
|
|
||||||
|
do {
|
||||||
|
$attempts++;
|
||||||
|
$event = $this->releaseAutoSyncEventById($eventId);
|
||||||
|
if ($event === null) {
|
||||||
|
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastStatus = (string)($event['status'] ?? 'unknown');
|
||||||
|
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
|
||||||
|
$deployment = null;
|
||||||
|
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
|
||||||
|
if ($deploymentId !== null) {
|
||||||
|
$deployment = $this->getDeployment($deploymentId);
|
||||||
|
}
|
||||||
|
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
|
||||||
|
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
|
||||||
|
return [
|
||||||
|
'step_status' => 'passed',
|
||||||
|
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||||
|
'deployment' => $this->publicDeployment($deployment),
|
||||||
|
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||||
|
'attempts' => $attempts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
|
||||||
|
$eventId,
|
||||||
|
strtoupper($app),
|
||||||
|
$commitSha
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastStatus === 'failed') {
|
||||||
|
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
|
||||||
|
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time() >= $deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep($pollInterval);
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
|
||||||
|
$eventId,
|
||||||
|
$timeout,
|
||||||
|
$lastStatus
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private function upsertReleaseAutoSyncEvent(array $input): array
|
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||||
{
|
{
|
||||||
$channelId = (int)$input['channel_id'];
|
$channelId = (int)$input['channel_id'];
|
||||||
@@ -6346,6 +6403,7 @@ class release_manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resourceType === 'application') {
|
if ($resourceType === 'application') {
|
||||||
|
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
|
||||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||||
} else {
|
} else {
|
||||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||||
@@ -6358,27 +6416,45 @@ class release_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
|
||||||
|
{
|
||||||
|
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
|
||||||
|
if ($keys === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
|
||||||
|
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
|
||||||
|
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->deleteApplicationEnv($resourceUuid, $uuid);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||||
{
|
{
|
||||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||||
$env = $contextEnv;
|
$env = $contextEnv;
|
||||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||||
if ($deploymentCommitSha !== '') {
|
|
||||||
if ($app === 'frontend') {
|
|
||||||
foreach (['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'] as $key) {
|
|
||||||
$env[$key] = $deploymentCommitSha;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
|
|
||||||
if (!array_key_exists($key, $contextEnv)) {
|
|
||||||
$env[$key] = $deploymentCommitSha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($app !== 'api') {
|
if ($app !== 'api') {
|
||||||
|
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||||
return $env;
|
return $env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6403,9 +6479,34 @@ class release_manager
|
|||||||
$env = array_replace($env, $contextEnv);
|
$env = array_replace($env, $contextEnv);
|
||||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||||
|
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
|
||||||
|
{
|
||||||
|
if ($deploymentCommitSha === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
|
||||||
|
$env[$key] = $deploymentCommitSha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
|
||||||
|
{
|
||||||
|
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
|
||||||
|
if ($app === 'frontend') {
|
||||||
|
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
|
||||||
|
}
|
||||||
|
if ($app === 'api') {
|
||||||
|
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
private function releaseCoolifyContextEnv(array $context): array
|
private function releaseCoolifyContextEnv(array $context): array
|
||||||
{
|
{
|
||||||
$env = [];
|
$env = [];
|
||||||
@@ -9035,7 +9136,7 @@ class release_manager
|
|||||||
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
|
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
|
||||||
if (is_array($payload[$key] ?? null)) {
|
if (is_array($payload[$key] ?? null)) {
|
||||||
return $this->payloadRows($payload[$key]);
|
return $this->payloadRows($payload[$key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,33 @@
|
|||||||
|
|
||||||
app_require('classes/release_manager.php');
|
app_require('classes/release_manager.php');
|
||||||
app_require('classes/release_manager_schema_bootstrap.php');
|
app_require('classes/release_manager_schema_bootstrap.php');
|
||||||
|
app_require('classes/coolify_api_client.php');
|
||||||
|
|
||||||
|
use classes\coolify_api_client;
|
||||||
use classes\release_manager;
|
use classes\release_manager;
|
||||||
|
|
||||||
|
class ReleaseManagerCoolifyEnvFake extends coolify_api_client
|
||||||
|
{
|
||||||
|
public array $envRows;
|
||||||
|
public array $deleted = [];
|
||||||
|
|
||||||
|
public function __construct(array $envRows)
|
||||||
|
{
|
||||||
|
$this->envRows = $envRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listApplicationEnvs(string $uuid): array
|
||||||
|
{
|
||||||
|
return $this->envRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
||||||
|
{
|
||||||
|
$this->deleted[] = [$uuid, $envUuid];
|
||||||
|
return ['message' => 'deleted'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it('redacts sensitive release timeline payload fields recursively', function (): void {
|
it('redacts sensitive release timeline payload fields recursively', function (): void {
|
||||||
$payload = [
|
$payload = [
|
||||||
'Authorization' => 'Bearer secret-token',
|
'Authorization' => 'Bearer secret-token',
|
||||||
@@ -608,7 +632,7 @@ it('resolves backend commit sha from API runtime environment in priority order',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
|
it('forces selected API commit into generated Coolify runtime env keys', function (): void {
|
||||||
$manager = new release_manager();
|
$manager = new release_manager();
|
||||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||||
$runtimeEnv->setAccessible(true);
|
$runtimeEnv->setAccessible(true);
|
||||||
@@ -621,12 +645,17 @@ it('injects selected API commit into Coolify runtime env unless explicitly set',
|
|||||||
'commit_sha' => $selectedCommit,
|
'commit_sha' => $selectedCommit,
|
||||||
], [
|
], [
|
||||||
'coolify_env' => [
|
'coolify_env' => [
|
||||||
|
'API_COMMIT_SHA' => $explicitCommit,
|
||||||
'COMMIT_SHA' => $explicitCommit,
|
'COMMIT_SHA' => $explicitCommit,
|
||||||
|
'GITHUB_SHA' => $explicitCommit,
|
||||||
|
'RELEASE_COMMIT_SHA' => $explicitCommit,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
|
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
|
expect($env['GITHUB_SHA'])->toBe($selectedCommit);
|
||||||
|
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
|
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
|
||||||
@@ -1438,6 +1467,36 @@ it('collects previous Coolify application UUIDs for stale route cleanup', functi
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deletes only generated API commit env rows before Coolify application env updates', function (): void {
|
||||||
|
$manager = new release_manager();
|
||||||
|
$deleteCommitEnvs = new ReflectionMethod(release_manager::class, 'deleteCoolifyGeneratedCommitEnvs');
|
||||||
|
$deleteCommitEnvs->setAccessible(true);
|
||||||
|
$client = new ReleaseManagerCoolifyEnvFake([
|
||||||
|
['uuid' => 'api-commit', 'key' => 'API_COMMIT_SHA'],
|
||||||
|
['uuid' => 'commit', 'key' => 'COMMIT_SHA'],
|
||||||
|
['uuid' => 'github', 'key' => 'GITHUB_SHA'],
|
||||||
|
['uuid' => 'release', 'key' => 'RELEASE_COMMIT_SHA'],
|
||||||
|
['uuid' => 'frontend-source', 'key' => 'SOURCE_COMMIT'],
|
||||||
|
['uuid' => 'secret', 'key' => 'CONFIG_DB_PASSWORD'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleteCommitEnvs->invoke($manager, $client, 'application-uuid', ['app' => 'api'], []);
|
||||||
|
|
||||||
|
expect($client->deleted)->toBe([
|
||||||
|
['application-uuid', 'api-commit'],
|
||||||
|
['application-uuid', 'commit'],
|
||||||
|
['application-uuid', 'github'],
|
||||||
|
['application-uuid', 'release'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for an in-flight automatic sync instead of passing the retry immediately', function (): void {
|
||||||
|
$manager = file_get_contents(app_path('classes/release_manager.php'));
|
||||||
|
|
||||||
|
expect($manager)->toContain('return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);')
|
||||||
|
->and($manager)->toContain('Automatic container update is already being processed for event %d but did not finish');
|
||||||
|
});
|
||||||
|
|
||||||
it('redacts GitHub access metadata from public release versions', function (): void {
|
it('redacts GitHub access metadata from public release versions', function (): void {
|
||||||
$manager = new release_manager();
|
$manager = new release_manager();
|
||||||
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
||||||
|
|||||||
Reference in New Issue
Block a user