Merge remote-tracking branch 'origin/next' into mcp-server-instance-toggle
This commit is contained in:
commit
d057ce5172
8 changed files with 237 additions and 18 deletions
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
use App\Events\SentinelRestarted;
|
use App\Events\SentinelRestarted;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServerSetting;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class StartSentinel
|
class StartSentinel
|
||||||
|
|
@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
||||||
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
|
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
|
||||||
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
|
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
|
||||||
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
|
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
|
||||||
$token = data_get($server, 'settings.sentinel_token');
|
$token = $server->settings->ensureValidSentinelToken();
|
||||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
|
||||||
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
|
|
||||||
}
|
|
||||||
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
||||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||||
$mountDir = '/data/coolify/sentinel';
|
$mountDir = '/data/coolify/sentinel';
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class UploadController extends BaseController
|
||||||
'archive.gz',
|
'archive.gz',
|
||||||
'bz2',
|
'bz2',
|
||||||
'xz',
|
'xz',
|
||||||
|
'dmp',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function upload(Request $request)
|
public function upload(Request $request)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class Email extends Component
|
||||||
public ?string $smtpPort = null;
|
public ?string $smtpPort = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
|
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
|
||||||
public ?string $smtpEncryption = null;
|
public ?string $smtpEncryption = 'starttls';
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
#[Validate(['nullable', 'string'])]
|
||||||
public ?string $smtpUsername = null;
|
public ?string $smtpUsername = null;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -144,19 +145,54 @@ protected static function booted()
|
||||||
* Validate that a sentinel token contains only safe characters.
|
* Validate that a sentinel token contains only safe characters.
|
||||||
* Prevents OS command injection when the token is interpolated into shell commands.
|
* Prevents OS command injection when the token is interpolated into shell commands.
|
||||||
*/
|
*/
|
||||||
public static function isValidSentinelToken(string $token): bool
|
public static function isValidSentinelToken(?string $token): bool
|
||||||
{
|
{
|
||||||
|
if ($token === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
|
/**
|
||||||
|
* Returns a valid sentinel token, regenerating it if the stored value is
|
||||||
|
* empty, undecryptable, or otherwise invalid. Throws only when regeneration
|
||||||
|
* still fails to produce a valid token.
|
||||||
|
*/
|
||||||
|
public function ensureValidSentinelToken(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$token = $this->sentinel_token;
|
||||||
|
} catch (DecryptException) {
|
||||||
|
$token = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isValidSentinelToken($token)) {
|
||||||
|
// Clear undecryptable raw value so Eloquent's dirty-check won't try to
|
||||||
|
// decrypt the bad original during save().
|
||||||
|
$attrs = $this->getAttributes();
|
||||||
|
$attrs['sentinel_token'] = null;
|
||||||
|
$this->setRawAttributes($attrs, true);
|
||||||
|
|
||||||
|
$this->generateSentinelToken(save: true, ignoreEvent: true);
|
||||||
|
$this->refresh();
|
||||||
|
$token = $this->sentinel_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isValidSentinelToken($token)) {
|
||||||
|
throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'server_uuid' => $this->server->uuid,
|
'server_uuid' => $this->server->uuid,
|
||||||
];
|
];
|
||||||
$token = json_encode($data);
|
$token = encrypt(json_encode($data));
|
||||||
$encrypted = encrypt($token);
|
$this->sentinel_token = $token;
|
||||||
$this->sentinel_token = $encrypted;
|
|
||||||
if ($save) {
|
if ($save) {
|
||||||
if ($ignoreEvent) {
|
if ($ignoreEvent) {
|
||||||
$this->saveQuietly();
|
$this->saveQuietly();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Traits;
|
namespace App\Traits;
|
||||||
|
|
||||||
use App\Models\ServerSetting;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
trait HasMetrics
|
trait HasMetrics
|
||||||
{
|
{
|
||||||
|
|
@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
||||||
$endpoint = $this->getMetricsEndpoint($type, $from);
|
$endpoint = $this->getMetricsEndpoint($type, $from);
|
||||||
|
|
||||||
$token = $server->settings->sentinel_token;
|
$previousToken = null;
|
||||||
if (! ServerSetting::isValidSentinelToken($token)) {
|
try {
|
||||||
throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
|
$previousToken = $server->settings->sentinel_token;
|
||||||
|
} catch (DecryptException) {
|
||||||
|
// fall through to ensureValidSentinelToken which will regenerate
|
||||||
|
}
|
||||||
|
$token = $server->settings->ensureValidSentinelToken();
|
||||||
|
if ($token !== $previousToken) {
|
||||||
|
Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = instant_remote_process(
|
$response = instant_remote_process(
|
||||||
|
|
@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
|
||||||
|
|
||||||
private function isServerMetrics(): bool
|
private function isServerMetrics(): bool
|
||||||
{
|
{
|
||||||
return $this instanceof \App\Models\Server;
|
return $this instanceof Server;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getMetricsServer(): \App\Models\Server
|
private function getMetricsServer(): Server
|
||||||
{
|
{
|
||||||
return $this->isServerMetrics() ? $this : $this->destination->server;
|
return $this->isServerMetrics() ? $this : $this->destination->server;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
|
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
|
||||||
{
|
{
|
||||||
|
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
|
||||||
$application_id = $application->id;
|
$application_id = $application->id;
|
||||||
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
|
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
|
||||||
$deployment_url = $deployment_link->getPath();
|
$deployment_url = $deployment_link->getPath();
|
||||||
|
|
|
||||||
107
tests/Feature/QueueApplicationDeploymentCommitTest.php
Normal file
107
tests/Feature/QueueApplicationDeploymentCommitTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\ApplicationDeploymentJob;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Bus::fake([ApplicationDeploymentJob::class]);
|
||||||
|
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->destination = StandaloneDocker::factory()->create([
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'network' => 'test-network-'.fake()->unique()->word(),
|
||||||
|
]);
|
||||||
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
|
||||||
|
{
|
||||||
|
$attributes = [
|
||||||
|
'environment_id' => $environmentId,
|
||||||
|
'destination_id' => $destinationId,
|
||||||
|
'destination_type' => StandaloneDocker::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($gitCommitSha !== null) {
|
||||||
|
$attributes['git_commit_sha'] = $gitCommitSha;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application::factory()->create($attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('queue_application_deployment commit resolution', function () {
|
||||||
|
test('uses application git_commit_sha when commit parameter omitted', function () {
|
||||||
|
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||||
|
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||||
|
|
||||||
|
$result = queue_application_deployment(
|
||||||
|
application: $application,
|
||||||
|
deployment_uuid: 'test-deploy-uuid-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('queued');
|
||||||
|
|
||||||
|
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
|
||||||
|
expect($deployment)->not->toBeNull();
|
||||||
|
expect($deployment->commit)->toBe($pinnedSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
|
||||||
|
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||||
|
|
||||||
|
$result = queue_application_deployment(
|
||||||
|
application: $application,
|
||||||
|
deployment_uuid: 'test-deploy-uuid-2',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('queued');
|
||||||
|
|
||||||
|
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
|
||||||
|
expect($deployment->commit)->toBe('HEAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit commit parameter overrides application git_commit_sha', function () {
|
||||||
|
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||||
|
$webhookSha = '111222333444555666777888999000aaabbbccc1';
|
||||||
|
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||||
|
|
||||||
|
$result = queue_application_deployment(
|
||||||
|
application: $application,
|
||||||
|
deployment_uuid: 'test-deploy-uuid-3',
|
||||||
|
commit: $webhookSha,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('queued');
|
||||||
|
|
||||||
|
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
|
||||||
|
expect($deployment->commit)->toBe($webhookSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
|
||||||
|
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||||
|
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||||
|
|
||||||
|
$result = queue_application_deployment(
|
||||||
|
application: $application,
|
||||||
|
deployment_uuid: 'test-deploy-uuid-4',
|
||||||
|
commit: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('queued');
|
||||||
|
|
||||||
|
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
|
||||||
|
expect($deployment->commit)->toBe($pinnedSha);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Models\ServerSetting;
|
use App\Models\ServerSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
|
@ -78,11 +79,73 @@
|
||||||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns false for null sentinel token', function () {
|
||||||
|
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects the reported PoC payload', function () {
|
it('rejects the reported PoC payload', function () {
|
||||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ServerSetting::ensureValidSentinelToken', function () {
|
||||||
|
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
|
||||||
|
$settings = $this->server->settings;
|
||||||
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||||
|
|
||||||
|
$settings->refresh();
|
||||||
|
$token = $settings->ensureValidSentinelToken();
|
||||||
|
|
||||||
|
expect($token)->not->toBeEmpty();
|
||||||
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||||
|
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates token when stored value cannot be decrypted', function () {
|
||||||
|
$settings = $this->server->settings;
|
||||||
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
|
||||||
|
|
||||||
|
$settings->refresh();
|
||||||
|
$token = $settings->ensureValidSentinelToken();
|
||||||
|
|
||||||
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||||
|
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns existing valid token without regenerating', function () {
|
||||||
|
$settings = $this->server->settings;
|
||||||
|
$original = $settings->sentinel_token;
|
||||||
|
|
||||||
|
$token = $settings->ensureValidSentinelToken();
|
||||||
|
|
||||||
|
expect($token)->toBe($original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws RuntimeException only when regeneration also fails', function () {
|
||||||
|
$settings = $this->server->settings;
|
||||||
|
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||||
|
|
||||||
|
$stub = new class extends ServerSetting
|
||||||
|
{
|
||||||
|
protected $table = 'server_settings';
|
||||||
|
|
||||||
|
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
||||||
|
{
|
||||||
|
DB::table('server_settings')->where('id', $this->id)->update([
|
||||||
|
'sentinel_token' => encrypt('invalid token with spaces!'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
|
||||||
|
$stub->exists = true;
|
||||||
|
|
||||||
|
expect(fn () => $stub->ensureValidSentinelToken())
|
||||||
|
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('generated sentinel tokens are valid', function () {
|
describe('generated sentinel tokens are valid', function () {
|
||||||
it('generates tokens that pass format validation', function () {
|
it('generates tokens that pass format validation', function () {
|
||||||
$settings = $this->server->settings;
|
$settings = $this->server->settings;
|
||||||
|
|
@ -92,4 +155,11 @@
|
||||||
expect($token)->not->toBeEmpty();
|
expect($token)->not->toBeEmpty();
|
||||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the same value the cast reads back', function () {
|
||||||
|
$settings = $this->server->settings;
|
||||||
|
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
|
||||||
|
|
||||||
|
expect($settings->fresh()->sentinel_token)->toBe($returned);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue