mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Merge branch 'next' into feature/horizon-configurable-admin-access
This commit is contained in:
@@ -13,7 +13,7 @@ class StopApplication
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
|
||||
{
|
||||
$servers = collect([$application->destination->server]);
|
||||
if ($application?->additional_servers?->count() > 0) {
|
||||
@@ -28,7 +28,7 @@ class StopApplication
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
|
||||
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
$containers = $previewDeployments
|
||||
@@ -36,10 +36,11 @@ class StopApplication
|
||||
: getCurrentApplicationContainerStatus($server, $application->id, 0);
|
||||
|
||||
$containersToStop = $containers->pluck('Names')->toArray();
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t 30 $containerName",
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
@@ -56,12 +57,15 @@ class StopApplication
|
||||
}
|
||||
}
|
||||
|
||||
// Reset restart tracking when application is manually stopped
|
||||
$application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
$status = ['status' => 'exited'];
|
||||
if ($resetRestartCount) {
|
||||
$status = array_merge($status, [
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
$application->update($status);
|
||||
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,15 @@ class StopApplicationOneServer
|
||||
}
|
||||
try {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
"docker stop -t 30 $containerName",
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
|
||||
@@ -50,13 +50,9 @@ class StartClickhouse
|
||||
],
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -98,6 +94,9 @@ class StartClickhouse
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -105,7 +104,7 @@ class StartClickhouse
|
||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
@@ -11,12 +11,16 @@ use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
|
||||
class StartDatabase
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
public function configureJob(JobDecorator $job): void
|
||||
{
|
||||
$job->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
|
||||
{
|
||||
@@ -25,28 +29,28 @@ class StartDatabase
|
||||
return 'Server is not functional';
|
||||
}
|
||||
switch ($database->getMorphClass()) {
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
case StandalonePostgresql::class:
|
||||
$activity = StartPostgresql::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneRedis::class:
|
||||
case StandaloneRedis::class:
|
||||
$activity = StartRedis::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
case StandaloneMongodb::class:
|
||||
$activity = StartMongodb::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
case StandaloneMysql::class:
|
||||
$activity = StartMysql::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
case StandaloneMariadb::class:
|
||||
$activity = StartMariadb::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneKeydb::class:
|
||||
case StandaloneKeydb::class:
|
||||
$activity = StartKeydb::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneDragonfly::class:
|
||||
case StandaloneDragonfly::class:
|
||||
$activity = StartDragonfly::run($database);
|
||||
break;
|
||||
case \App\Models\StandaloneClickhouse::class:
|
||||
case StandaloneClickhouse::class:
|
||||
$activity = StartClickhouse::run($database);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -11,14 +11,19 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartDatabaseProxy
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
public function configureJob(JobDecorator $job): void
|
||||
{
|
||||
$job->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
|
||||
{
|
||||
@@ -29,7 +34,7 @@ class StartDatabaseProxy
|
||||
$proxyContainerName = "{$database->uuid}-proxy";
|
||||
$isSSLEnabled = $database->enable_ssl ?? false;
|
||||
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($database->getMorphClass() === ServiceDatabase::class) {
|
||||
$databaseType = $database->databaseType();
|
||||
$network = $database->service->uuid;
|
||||
$server = data_get($database, 'service.destination.server');
|
||||
@@ -132,14 +137,12 @@ class StartDatabaseProxy
|
||||
?? data_get($database, 'service.environment.project.team');
|
||||
|
||||
$team?->notify(
|
||||
new \App\Notifications\Container\ContainerRestarted(
|
||||
new ContainerRestarted(
|
||||
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
|
||||
$server,
|
||||
)
|
||||
);
|
||||
|
||||
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,13 +106,9 @@ class StartDragonfly
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -182,6 +178,9 @@ class StartDragonfly
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -192,7 +191,7 @@ class StartDragonfly
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
@@ -108,13 +108,9 @@ class StartKeydb
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -166,7 +162,7 @@ class StartKeydb
|
||||
$docker_compose['volumes'] = $volume_names;
|
||||
}
|
||||
|
||||
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
|
||||
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[
|
||||
@@ -197,6 +193,9 @@ class StartKeydb
|
||||
// Add custom docker run options
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -210,7 +209,7 @@ class StartKeydb
|
||||
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
|
||||
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
@@ -103,13 +103,9 @@ class StartMariadb
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -175,7 +171,7 @@ class StartMariadb
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
|
||||
if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[
|
||||
@@ -202,6 +198,9 @@ class StartMariadb
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -209,13 +208,13 @@ class StartMariadb
|
||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, 'chown mysql:mysql /etc/mysql/certs/server.crt /etc/mysql/certs/server.key');
|
||||
}
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
}
|
||||
|
||||
@@ -109,17 +109,11 @@ class StartMongodb
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD',
|
||||
'echo',
|
||||
'ok',
|
||||
],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD',
|
||||
'echo',
|
||||
'ok',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -253,6 +247,9 @@ class StartMongodb
|
||||
$docker_compose['services'][$container_name]['command'] = $commandParts;
|
||||
}
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -260,12 +257,12 @@ class StartMongodb
|
||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem";
|
||||
}
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
@@ -340,7 +337,10 @@ class StartMongodb
|
||||
|
||||
private function add_default_database()
|
||||
{
|
||||
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
|
||||
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
|
||||
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
|
||||
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
|
||||
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
|
||||
$content_base64 = base64_encode($content);
|
||||
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
|
||||
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
|
||||
|
||||
@@ -103,13 +103,9 @@ class StartMysql
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -175,7 +171,7 @@ class StartMysql
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
|
||||
if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[
|
||||
@@ -203,6 +199,9 @@ class StartMysql
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -210,13 +209,12 @@ class StartMysql
|
||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
|
||||
@@ -110,16 +110,9 @@ class StartPostgresql
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
"psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
|
||||
],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -216,6 +209,9 @@ class StartPostgresql
|
||||
$docker_compose['services'][$container_name]['command'] = $command;
|
||||
}
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -223,12 +219,12 @@ class StartPostgresql
|
||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt";
|
||||
}
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
@@ -304,9 +300,18 @@ class StartPostgresql
|
||||
foreach ($this->database->init_scripts as $init_script) {
|
||||
$filename = data_get($init_script, 'filename');
|
||||
$content = data_get($init_script, 'content');
|
||||
|
||||
// Normalise filename without rejecting legacy values so previously created
|
||||
// init scripts keep deploying. basename() strips any directory components
|
||||
// (path traversal) and escapeshellarg() contains every shell metacharacter
|
||||
// in the tee target. Livewire / API validate new filenames up front.
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
$target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
|
||||
$escaped_target = escapeshellarg($target_path);
|
||||
$content_base64 = base64_encode($content);
|
||||
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
|
||||
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
|
||||
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
|
||||
$this->init_scripts[] = $target_path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,17 +105,11 @@ class StartRedis
|
||||
$this->database->destination->network,
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
'redis-cli',
|
||||
'ping',
|
||||
],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
'start_period' => '5s',
|
||||
],
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD-SHELL',
|
||||
'redis-cli',
|
||||
'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||
@@ -181,7 +175,7 @@ class StartRedis
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
|
||||
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
||||
'type' => 'bind',
|
||||
'source' => $this->configuration_dir.'/redis.conf',
|
||||
@@ -194,6 +188,9 @@ class StartRedis
|
||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||
|
||||
if (! $this->database->isHealthcheckEnabled()) {
|
||||
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||
}
|
||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$docker_compose_base64 = base64_encode($docker_compose);
|
||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||
@@ -207,7 +204,7 @@ class StartRedis
|
||||
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
|
||||
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
|
||||
}
|
||||
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
@@ -30,6 +30,7 @@ class StopDatabase
|
||||
|
||||
// Reset restart tracking when database is manually stopped
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
@@ -56,7 +57,7 @@ class StopDatabase
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t $timeout $containerName",
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Destination;
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
|
||||
class RemoveStandaloneDockerNetwork
|
||||
{
|
||||
public function handle(StandaloneDocker $destination): void
|
||||
{
|
||||
$safeNetwork = escapeshellarg($destination->network);
|
||||
|
||||
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
|
||||
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Docker;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Shared\ComplexStatusCheck;
|
||||
@@ -9,6 +10,7 @@ use App\Events\ServiceChecked;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -464,7 +466,9 @@ class GetContainersStatus
|
||||
}
|
||||
|
||||
// Wrap all database updates in a transaction to ensure consistency
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||
$restartLimitReached = false;
|
||||
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
@@ -475,16 +479,10 @@ class GetContainersStatus
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
|
||||
// Send notification
|
||||
$containerName = $application->name;
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$environmentName = data_get($application, 'environment.name');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
// Check if restart limit has been reached
|
||||
$maxAllowedRestarts = $application->max_restart_count ?? 0;
|
||||
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
|
||||
$restartLimitReached = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +497,12 @@ class GetContainersStatus
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($restartLimitReached) {
|
||||
$application->refresh();
|
||||
StopApplication::dispatch($application, false, true, false);
|
||||
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
@@ -11,6 +14,16 @@ use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
private const REGISTRATION_IP_MAX_ATTEMPTS = 3;
|
||||
|
||||
private const REGISTRATION_IP_DECAY_SECONDS = 600;
|
||||
|
||||
private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3;
|
||||
|
||||
private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600;
|
||||
|
||||
public function __construct(private readonly Request $request) {}
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
@@ -22,6 +35,9 @@ class CreateNewUser implements CreatesNewUsers
|
||||
if (! $settings->is_registration_enabled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->ensureRegistrationIsNotRateLimited($input);
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
@@ -44,7 +60,10 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
$user->save();
|
||||
$team = $user->teams()->first();
|
||||
$team = $user->teams()->first() ?? Team::find(0);
|
||||
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
// Disable registration after first user is created
|
||||
$settings = instanceSettings();
|
||||
@@ -68,4 +87,42 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
private function ensureRegistrationIsNotRateLimited(array $input): void
|
||||
{
|
||||
$keys = [
|
||||
[
|
||||
'key' => 'registration:ip:'.sha1($this->realIp()),
|
||||
'max' => self::REGISTRATION_IP_MAX_ATTEMPTS,
|
||||
'decay' => self::REGISTRATION_IP_DECAY_SECONDS,
|
||||
],
|
||||
];
|
||||
|
||||
$emailIdentity = normalize_email_identity($input['email'] ?? null);
|
||||
if ($emailIdentity !== null) {
|
||||
$keys[] = [
|
||||
'key' => 'registration:email-identity:'.sha1($emailIdentity),
|
||||
'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS,
|
||||
'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($keys as $limit) {
|
||||
if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) {
|
||||
abort(429, 'Too many registration attempts. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($keys as $limit) {
|
||||
RateLimiter::hit($limit['key'], $limit['decay']);
|
||||
}
|
||||
}
|
||||
|
||||
private function realIp(): string
|
||||
{
|
||||
return $this->request->server('REMOTE_ADDR') ?? $this->request->ip();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class ResetUserPassword implements ResetsUserPasswords
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
$user->deleteAllSessions();
|
||||
|
||||
@@ -24,7 +24,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
@@ -49,7 +49,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
|
||||
@@ -24,7 +24,7 @@ class StopProxy
|
||||
}
|
||||
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t=$timeout $containerName 2>/dev/null || true",
|
||||
dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true',
|
||||
"docker rm -f $containerName 2>/dev/null || true",
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
|
||||
@@ -20,10 +20,13 @@ class CleanupDocker
|
||||
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
|
||||
|
||||
$helperImageVersion = getHelperVersion();
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$helperImage = coolifyHelperImage();
|
||||
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
||||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||
$buildxMetadataVolume = isDev() && $server->isLocalhost()
|
||||
? 'coolify-buildx'
|
||||
: '$HOME/.docker/buildx';
|
||||
|
||||
$cleanupLog = [];
|
||||
|
||||
@@ -48,9 +51,10 @@ class CleanupDocker
|
||||
);
|
||||
|
||||
$commands = [
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
|
||||
$imagePruneCmd,
|
||||
'docker builder prune -af',
|
||||
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
|
||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||
|
||||
@@ -6,14 +6,16 @@ use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use App\Services\DigitalOceanService;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\VultrService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null)
|
||||
{
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
@@ -26,22 +28,32 @@ class DeleteServer
|
||||
);
|
||||
}
|
||||
|
||||
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) {
|
||||
$this->deleteFromVultrById(
|
||||
$vultrInstanceId ?? $server->vultr_instance_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
if ($deleteFromDigitalOcean && ($digitalOceanDropletId || ($server && $server->digitalocean_droplet_id))) {
|
||||
$this->deleteFromDigitalOceanById(
|
||||
$digitalOceanDropletId ?? $server->digitalocean_droplet_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
if (! $server) {
|
||||
return; // Server already force deleted from Coolify
|
||||
}
|
||||
|
||||
ray('force deleting server from Coolify', ['server_id' => $server->id]);
|
||||
|
||||
try {
|
||||
$server->forceDelete();
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
logger()->error('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
@@ -56,7 +68,10 @@ class DeleteServer
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::find($cloudProviderTokenId);
|
||||
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
|
||||
->where('team_id', $teamId)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
@@ -66,10 +81,6 @@ class DeleteServer
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
ray('No Hetzner token found for team, skipping Hetzner deletion', [
|
||||
'team_id' => $teamId,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -77,16 +88,7 @@ class DeleteServer
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$hetznerService->deleteServer($hetznerServerId);
|
||||
|
||||
ray('Deleted server from Hetzner', [
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// Log the error but don't prevent the server from being deleted from Coolify
|
||||
logger()->error('Failed to delete server from Hetzner', [
|
||||
@@ -100,4 +102,84 @@ class DeleteServer
|
||||
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
|
||||
->where('team_id', $teamId)
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
throw new \RuntimeException('No Vultr token found for the server team.');
|
||||
}
|
||||
|
||||
$vultrService = new VultrService($token->token);
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
|
||||
logger()->debug('Deleted server from Vultr', [
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('Failed to delete server from Vultr', [
|
||||
'error' => $e->getMessage(),
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
|
||||
->where('team_id', $teamId)
|
||||
->where('provider', 'digitalocean')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'digitalocean')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
throw new \RuntimeException('No DigitalOcean token found for the server team.');
|
||||
}
|
||||
|
||||
$digitalOceanService = new DigitalOceanService($token->token);
|
||||
$digitalOceanService->deleteDroplet($digitalOceanDropletId);
|
||||
|
||||
logger()->debug('Deleted droplet from DigitalOcean', [
|
||||
'digitalocean_droplet_id' => $digitalOceanDropletId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('Failed to delete droplet from DigitalOcean', [
|
||||
'error' => $e->getMessage(),
|
||||
'digitalocean_droplet_id' => $digitalOceanDropletId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class InstallDocker
|
||||
}');
|
||||
$found = StandaloneDocker::where('server_id', $server->id);
|
||||
if ($found->count() == 0 && $server->id) {
|
||||
StandaloneDocker::forceCreate([
|
||||
StandaloneDocker::create([
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify',
|
||||
'server_id' => $server->id,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ResourcesCheck
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$seconds = 60;
|
||||
try {
|
||||
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartLogDrain
|
||||
@@ -201,10 +202,29 @@ Files:
|
||||
"echo 'Starting Fluent Bit'",
|
||||
"cd $config_path && docker compose up -d",
|
||||
];
|
||||
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
|
||||
|
||||
return instant_remote_process($command, $server);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function logDrainNetworkConnectCommands(Server $server): array
|
||||
{
|
||||
if (! $server->isLogDrainEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $server->services()
|
||||
->with('destination')
|
||||
->where('connect_to_docker_network', true)
|
||||
->get()
|
||||
->map(fn (Service $service) => data_get($service, 'destination.network'))
|
||||
->filter()
|
||||
->unique()
|
||||
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Actions\Server;
|
||||
|
||||
use App\Events\SentinelRestarted;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartSentinel
|
||||
@@ -23,17 +22,11 @@ class StartSentinel
|
||||
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
|
||||
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
|
||||
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
|
||||
$token = data_get($server, 'settings.sentinel_token');
|
||||
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');
|
||||
$token = $server->settings->ensureValidSentinelToken();
|
||||
$endpoint = $server->settings->ensureSentinelUrl();
|
||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||
$mountDir = '/data/coolify/sentinel';
|
||||
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
|
||||
if (! $endpoint) {
|
||||
throw new \RuntimeException('You should set FQDN in Instance Settings.');
|
||||
}
|
||||
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
|
||||
$environments = [
|
||||
'TOKEN' => $token,
|
||||
'DEBUG' => $debug ? 'true' : 'false',
|
||||
|
||||
@@ -118,10 +118,14 @@ class UpdateCoolify
|
||||
{
|
||||
$latestHelperImageVersion = getHelperVersion();
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
$registryUrl = coolifyRegistryUrl();
|
||||
|
||||
remote_process([
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
||||
'bash /data/coolify/source/upgrade.sh '.
|
||||
escapeshellarg($this->latestVersion).' '.
|
||||
escapeshellarg($latestHelperImageVersion).' '.
|
||||
escapeshellarg($registryUrl),
|
||||
], $this->server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,44 @@ class ValidateServer
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
if (! $server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'validation_logs' => null,
|
||||
]);
|
||||
if ($server->vultr_instance_id) {
|
||||
$status = $server->refreshVultrState();
|
||||
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
|
||||
$this->error = $status === 'deleted'
|
||||
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
|
||||
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->digitalocean_droplet_id) {
|
||||
$status = $server->refreshDigitalOceanState();
|
||||
if (in_array($status, ['off', 'archive', 'deleted'], true)) {
|
||||
$this->error = $status === 'deleted'
|
||||
? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.'
|
||||
: 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
}
|
||||
|
||||
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class DeployServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$composeServiceName = $serviceApplication->name;
|
||||
|
||||
$service->parse();
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
|
||||
$workdir = $service->workdir();
|
||||
$composeFile = "{$workdir}/docker-compose.yml";
|
||||
$safeWorkdir = escapeshellarg($workdir);
|
||||
$safeComposeFile = escapeshellarg($composeFile);
|
||||
$safeProjectName = escapeshellarg($service->uuid);
|
||||
$safeComposeServiceName = escapeshellarg($composeServiceName);
|
||||
|
||||
$commands = collect([
|
||||
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
|
||||
'touch '.escapeshellarg("{$workdir}/.env"),
|
||||
]);
|
||||
|
||||
if ($pullLatestImages) {
|
||||
$commands->push('echo Pulling image for service.');
|
||||
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
|
||||
}
|
||||
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands->push('echo Creating Docker network.');
|
||||
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
|
||||
}
|
||||
|
||||
$upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps";
|
||||
if ($forceRebuild) {
|
||||
$upCommand .= ' --build';
|
||||
}
|
||||
$upCommand .= " {$safeComposeServiceName}";
|
||||
$commands->push('echo Starting service container.');
|
||||
$commands->push($upCommand);
|
||||
|
||||
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
|
||||
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$network = escapeshellarg($service->destination->network);
|
||||
$containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true");
|
||||
}
|
||||
|
||||
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ class RestartService
|
||||
|
||||
public function handle(Service $service, bool $pullLatestImages)
|
||||
{
|
||||
StopService::run($service);
|
||||
|
||||
return StartService::run($service, $pullLatestImages);
|
||||
return StartService::run(
|
||||
service: $service,
|
||||
pullLatestImages: $pullLatestImages,
|
||||
stopBeforeStart: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RestartServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker restart {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,22 @@ namespace App\Actions\Service;
|
||||
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartService
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
public function configureJob(JobDecorator $job): void
|
||||
{
|
||||
$job->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
||||
{
|
||||
$service->parse();
|
||||
if ($stopBeforeStart) {
|
||||
if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
|
||||
StopService::run(service: $service, dockerCleanup: false);
|
||||
}
|
||||
$service->saveComposeConfigs();
|
||||
@@ -46,7 +50,34 @@ class StartService
|
||||
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
|
||||
}
|
||||
}
|
||||
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
|
||||
|
||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
|
||||
private function logDrainNetworkConnectCommands(Service $service): array
|
||||
{
|
||||
if (! data_get($service, 'connect_to_docker_network')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $service->destination?->server?->isLogDrainEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$network = data_get($service, 'destination.network');
|
||||
|
||||
if (blank($network)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
|
||||
{
|
||||
return $stopBeforeStart && ! $pullLatestImages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ class StopService
|
||||
$this->stopContainersInParallel($containersToStop, $server);
|
||||
}
|
||||
|
||||
$applications->each->update(['status' => 'exited']);
|
||||
$dbs->each->update(['status' => 'exited']);
|
||||
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
}
|
||||
@@ -67,7 +70,7 @@ class StopService
|
||||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$commands[] = "docker stop -t $timeout $containerList";
|
||||
$commands[] = dockerStopCommand($timeout, $containerList, $server);
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker stop {$containerName}",
|
||||
], $server);
|
||||
|
||||
$serviceApplication->update(['status' => 'exited']);
|
||||
ServiceStatusChanged::dispatch($service->environment->project->team->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ServiceComposeUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UpdateServiceApplicationFromApi
|
||||
{
|
||||
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
|
||||
{
|
||||
$forceDomainOverride = $request->boolean('force_domain_override');
|
||||
|
||||
if (array_key_exists('url', $payload)) {
|
||||
$urlRaw = $payload['url'];
|
||||
if ($urlRaw !== null && ! is_string($urlRaw)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['url' => 'The url must be a string.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$parsed = ServiceComposeUrl::validateUrlString(
|
||||
is_string($urlRaw) ? $urlRaw : null,
|
||||
$forceDomainOverride
|
||||
);
|
||||
|
||||
if (count($parsed['errors']) > 0) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $parsed['errors'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($parsed['normalized'] !== null) {
|
||||
$containerUrls = str($parsed['normalized'])
|
||||
->explode(',')
|
||||
->map(fn ($url) => str(trim((string) $url))->lower());
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
|
||||
if (isset($result['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$result['error']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($result['hasConflicts'] && ! $forceDomainOverride) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => $result['conflicts'],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
}
|
||||
|
||||
if (array_key_exists('noindex_domains', $payload)) {
|
||||
// Must run after fqdn is set above: flags are kept only for current domains.
|
||||
$serviceApplication->setNoindexDomains($payload['noindex_domains'] ?? []);
|
||||
}
|
||||
|
||||
if (array_key_exists('human_name', $payload)) {
|
||||
$serviceApplication->human_name = $payload['human_name'];
|
||||
}
|
||||
|
||||
if (array_key_exists('description', $payload)) {
|
||||
$serviceApplication->description = $payload['description'];
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$serviceApplication->image = $payload['image'];
|
||||
}
|
||||
|
||||
if (array_key_exists('exclude_from_status', $payload)) {
|
||||
$serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_gzip_enabled', $payload)) {
|
||||
$serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_stripprefix_enabled', $payload)) {
|
||||
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_log_drain_enabled', $payload)) {
|
||||
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($enabled && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$serviceApplication->is_log_drain_enabled = $enabled;
|
||||
}
|
||||
|
||||
$serviceApplication->save();
|
||||
$serviceApplication->refresh();
|
||||
|
||||
updateCompose($serviceApplication);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteScheduledVolumeBackup
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(ScheduledVolumeBackup $backup, ?Server $server = null): void
|
||||
{
|
||||
$lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), $backup->timeout + 300);
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw new \RuntimeException('Wait for the queued or running storage backup to finish before deleting this schedule.');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($backup->executions()
|
||||
->where(fn ($query) => $query
|
||||
->where('status', 'running')
|
||||
->orWhere('stop_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->exists()) {
|
||||
throw new \RuntimeException('Wait for the running storage backup and recovery operations to finish before deleting this schedule.');
|
||||
}
|
||||
|
||||
$localFilenames = $backup->executions()
|
||||
->where('local_storage_deleted', false)
|
||||
->pluck('filename')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
if ($localFilenames !== []) {
|
||||
$server ??= $backup->server();
|
||||
if (! $server) {
|
||||
throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.');
|
||||
}
|
||||
|
||||
deleteBackupsLocally($localFilenames, $server, throwError: true);
|
||||
}
|
||||
|
||||
$s3Executions = $backup->executions()
|
||||
->with('s3')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->get();
|
||||
|
||||
foreach ($s3Executions->groupBy('s3_storage_id') as $executions) {
|
||||
$s3 = $executions->first()->s3;
|
||||
if (! $s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
}
|
||||
|
||||
$filenames = $executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $s3);
|
||||
}
|
||||
}
|
||||
|
||||
$backup->delete();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Jobs\FinalizeResourceMigrationJob;
|
||||
use App\Jobs\HostPathCloneJob;
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class MigrateResourceToDestination
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @return array{async: bool, volume_jobs: int, message: string}
|
||||
*/
|
||||
public function handle(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
StandaloneDocker|SwarmDocker $destination,
|
||||
bool $migrateVolumes = true,
|
||||
): array {
|
||||
if (! isDev()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource migration is only available in development mode.',
|
||||
]);
|
||||
}
|
||||
|
||||
$resource->loadMissing(['destination.server']);
|
||||
$sourceDestination = $resource->destination;
|
||||
|
||||
if (! $sourceDestination) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource has no destination to migrate from.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
(int) $sourceDestination->id === (int) $destination->id
|
||||
&& $sourceDestination->getMorphClass() === $destination->getMorphClass()
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource is already on the selected destination.',
|
||||
]);
|
||||
}
|
||||
|
||||
$sourceServer = $sourceDestination->server;
|
||||
$targetServer = $destination->server;
|
||||
|
||||
if (! $targetServer) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Target destination has no server.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $targetServer->canHostResources()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'The selected server cannot host resources.',
|
||||
]);
|
||||
}
|
||||
|
||||
$targetServer->refresh();
|
||||
if (! $targetServer->isFunctional()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Target server is not validated and reachable.',
|
||||
]);
|
||||
}
|
||||
|
||||
$crossServer = $sourceServer && (int) $sourceServer->id !== (int) $targetServer->id;
|
||||
|
||||
if (! $crossServer) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Migration requires a different server. Choose another server destination.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($migrateVolumes) {
|
||||
if (! $sourceServer?->isFunctional()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Source server is not functional. Cannot migrate volume data.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->stopResource($resource);
|
||||
|
||||
$jobs = [];
|
||||
if ($migrateVolumes) {
|
||||
$jobs = $this->buildVolumeJobs($resource, $sourceServer, $targetServer);
|
||||
}
|
||||
|
||||
if ($jobs !== []) {
|
||||
Bus::chain([
|
||||
...$jobs,
|
||||
new FinalizeResourceMigrationJob($resource, $destination),
|
||||
])->dispatch();
|
||||
|
||||
return [
|
||||
'async' => true,
|
||||
'volume_jobs' => count($jobs),
|
||||
'message' => 'Migration started. The resource was stopped and volume data is being transferred. Destination will update when transfer completes. Redeploy afterwards.',
|
||||
];
|
||||
}
|
||||
|
||||
$this->applyDestination($resource, $destination);
|
||||
|
||||
return [
|
||||
'async' => false,
|
||||
'volume_jobs' => 0,
|
||||
'message' => $migrateVolumes
|
||||
? 'Resource migrated to the new server. Redeploy when ready.'
|
||||
: 'Resource migrated to the new server. Volume data was not transferred. Redeploy when ready.',
|
||||
];
|
||||
}
|
||||
|
||||
public function applyDestination(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
StandaloneDocker|SwarmDocker $destination,
|
||||
): void {
|
||||
$payload = [
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
$payload['server_id'] = $destination->server_id;
|
||||
} else {
|
||||
// Service status is computed from child containers, not a DB column.
|
||||
$payload['status'] = 'exited';
|
||||
$payload['started_at'] = null;
|
||||
}
|
||||
|
||||
$resource->fill($payload)->save();
|
||||
|
||||
if ($resource instanceof Application) {
|
||||
$resource->additional_networks()->detach();
|
||||
$this->regenerateApplicationLabels($resource->fresh(['destination.server', 'settings']));
|
||||
}
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications() as $application) {
|
||||
$application->fill(['status' => 'exited'])->save();
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$database->fill(['status' => 'exited'])->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->resaveFileStorages($resource->fresh());
|
||||
}
|
||||
|
||||
protected function stopResource(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
): void {
|
||||
try {
|
||||
if ($resource instanceof Application) {
|
||||
StopApplication::run($resource, previewDeployments: false, dockerCleanup: false);
|
||||
} elseif ($resource instanceof Service) {
|
||||
StopService::run($resource, deleteConnectedNetworks: false, dockerCleanup: false);
|
||||
} else {
|
||||
StopDatabase::run($resource, dockerCleanup: false);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Failed to stop resource during migration: '.$e->getMessage(), [
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
'resource_uuid' => $resource->uuid ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, VolumeCloneJob|HostPathCloneJob>
|
||||
*/
|
||||
protected function buildVolumeJobs(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
$sourceServer,
|
||||
$targetServer,
|
||||
): array {
|
||||
$jobs = [];
|
||||
$seenNamedVolumes = [];
|
||||
$seenHostPaths = [];
|
||||
|
||||
foreach ($this->collectPersistentVolumes($resource) as $volume) {
|
||||
if (! $volume instanceof LocalPersistentVolume) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hostPath = filled($volume->host_path) ? (string) $volume->host_path : null;
|
||||
|
||||
if ($hostPath) {
|
||||
if (isset($seenHostPaths[$hostPath])) {
|
||||
continue;
|
||||
}
|
||||
$seenHostPaths[$hostPath] = true;
|
||||
$jobs[] = new HostPathCloneJob($hostPath, $hostPath, $sourceServer, $targetServer);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) $volume->name;
|
||||
if ($name === '' || isset($seenNamedVolumes[$name])) {
|
||||
continue;
|
||||
}
|
||||
$seenNamedVolumes[$name] = true;
|
||||
$jobs[] = new VolumeCloneJob($name, $name, $sourceServer, $targetServer, $volume);
|
||||
}
|
||||
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, LocalPersistentVolume>
|
||||
*/
|
||||
protected function collectPersistentVolumes(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
) {
|
||||
if ($resource instanceof Service) {
|
||||
$volumes = collect();
|
||||
foreach ($resource->applications() as $application) {
|
||||
$volumes = $volumes->merge($application->persistentStorages()->get());
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$volumes = $volumes->merge($database->persistentStorages()->get());
|
||||
}
|
||||
|
||||
return $volumes;
|
||||
}
|
||||
|
||||
return $resource->persistentStorages()->get();
|
||||
}
|
||||
|
||||
protected function regenerateApplicationLabels(Application $application): void
|
||||
{
|
||||
$settings = $application->settings;
|
||||
if (! $settings || ! $settings->is_container_label_readonly_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($application->destination?->server?->proxyType() === 'NONE') {
|
||||
return;
|
||||
}
|
||||
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->custom_labels = base64_encode($customLabels);
|
||||
$application->save();
|
||||
}
|
||||
|
||||
protected function resaveFileStorages(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
): void {
|
||||
$fileStorages = collect();
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications() as $application) {
|
||||
$fileStorages = $fileStorages->merge($application->fileStorages()->get());
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$fileStorages = $fileStorages->merge($database->fileStorages()->get());
|
||||
}
|
||||
} elseif (method_exists($resource, 'fileStorages')) {
|
||||
$fileStorages = $resource->fileStorages()->get();
|
||||
}
|
||||
|
||||
foreach ($fileStorages as $storage) {
|
||||
if ($storage->is_host_file) {
|
||||
continue;
|
||||
}
|
||||
ServerStorageSaveJob::dispatch($storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Actions\Stripe;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscription
|
||||
@@ -21,7 +22,7 @@ class CancelSubscription
|
||||
$this->isDryRun = $isDryRun;
|
||||
|
||||
if (! $isDryRun && isCloud()) {
|
||||
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$this->stripe = app(StripeClient::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ class CancelSubscription
|
||||
];
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = app(StripeClient::class);
|
||||
$subscriptions = $this->getSubscriptionsPreview();
|
||||
|
||||
$verified = collect();
|
||||
@@ -88,7 +89,7 @@ class CancelSubscription
|
||||
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
||||
]);
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
// Subscription doesn't exist in Stripe
|
||||
$notFound->push([
|
||||
'subscription' => $subscription,
|
||||
@@ -181,7 +182,7 @@ class CancelSubscription
|
||||
return false;
|
||||
}
|
||||
|
||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = app(StripeClient::class);
|
||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||
|
||||
// Update local record if exists
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CancelSubscriptionAtPeriodEnd
|
||||
@@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,7 @@ class CancelSubscriptionAtPeriodEnd
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Carbon\Carbon;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class RefundSubscription
|
||||
@@ -13,7 +15,7 @@ class RefundSubscription
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +41,7 @@ class RefundSubscription
|
||||
|
||||
try {
|
||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
return $this->ineligible('Subscription not found in Stripe.');
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ class RefundSubscription
|
||||
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||
$startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||
$daysSinceStart = (int) $startDate->diffInDays(now());
|
||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
||||
|
||||
@@ -130,7 +132,7 @@ class RefundSubscription
|
||||
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Team;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class ResumeSubscription
|
||||
@@ -11,7 +12,7 @@ class ResumeSubscription
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ class ResumeSubscription
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
|
||||
+83
-37
@@ -1,29 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
namespace App\Actions\Stripe;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
class SyncStripeSubscriptions
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use AsAction;
|
||||
|
||||
public int $tries = 1;
|
||||
private const VALID_STRIPE_STATUSES = ['active', 'past_due'];
|
||||
|
||||
public int $timeout = 1800; // 30 minutes max
|
||||
|
||||
public function __construct(public bool $fix = false)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(?\Closure $onProgress = null): array
|
||||
public function handle(bool $fix = false, ?\Closure $onProgress = null): array
|
||||
{
|
||||
if (! isCloud() || ! isStripe()) {
|
||||
return ['error' => 'Not running on Cloud or Stripe not configured'];
|
||||
@@ -33,7 +22,9 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->where('stripe_invoice_paid', true)
|
||||
->get();
|
||||
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = app()->bound(StripeClient::class)
|
||||
? app(StripeClient::class)
|
||||
: new StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
||||
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
||||
@@ -42,13 +33,20 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$staleSubscriptions = $subscriptions->filter(
|
||||
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
|
||||
);
|
||||
$staleSubscriptionCount = $staleSubscriptions->count();
|
||||
|
||||
$onProgress?->__invoke('checking', 0, $staleSubscriptionCount);
|
||||
|
||||
// For each stale subscription, get the exact Stripe status and check for resubscriptions
|
||||
$discrepancies = [];
|
||||
$resubscribed = [];
|
||||
$errors = [];
|
||||
$fixedCount = 0;
|
||||
$manualReviewCount = 0;
|
||||
|
||||
foreach ($staleSubscriptions->values() as $index => $subscription) {
|
||||
$onProgress?->__invoke('checking', $index + 1, $staleSubscriptionCount);
|
||||
|
||||
foreach ($staleSubscriptions as $subscription) {
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
@@ -65,8 +63,18 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this user resubscribed under a different customer/subscription
|
||||
if (in_array($stripeStatus, self::VALID_STRIPE_STATUSES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
|
||||
$validReplacement = Subscription::query()
|
||||
->where('team_id', $subscription->team_id)
|
||||
->where('id', '!=', $subscription->id)
|
||||
->where('stripe_invoice_paid', true)
|
||||
->whereIn('stripe_subscription_id', $validStripeIds)
|
||||
->first();
|
||||
|
||||
if ($activeSub) {
|
||||
$resubscribed[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
@@ -77,33 +85,69 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'new_stripe_subscription_id' => $activeSub['subscription_id'],
|
||||
'new_stripe_customer_id' => $activeSub['customer_id'],
|
||||
'new_status' => $activeSub['status'],
|
||||
'linked_to_team' => $validReplacement?->stripe_subscription_id === $activeSub['subscription_id'],
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$inactiveSubscription = null;
|
||||
if (! $validReplacement && ! $activeSub) {
|
||||
$inactiveSubscription = Subscription::query()
|
||||
->where('team_id', $subscription->team_id)
|
||||
->where('id', '!=', $subscription->id)
|
||||
->where('stripe_invoice_paid', false)
|
||||
->first();
|
||||
}
|
||||
|
||||
$resolution = match (true) {
|
||||
(bool) $validReplacement => 'delete_stale',
|
||||
(bool) $activeSub => 'manual_review',
|
||||
(bool) $inactiveSubscription => 'delete_stale',
|
||||
default => 'end_subscription',
|
||||
};
|
||||
|
||||
$discrepancies[] = [
|
||||
'subscription_id' => $subscription->id,
|
||||
'team_id' => $subscription->team_id,
|
||||
'stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||
'stripe_status' => $stripeStatus,
|
||||
'resolution' => $resolution,
|
||||
];
|
||||
|
||||
if ($this->fix) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
if ($fix) {
|
||||
$team = $subscription->team;
|
||||
|
||||
if ($stripeStatus === 'canceled') {
|
||||
$subscription->team?->subscriptionEnded();
|
||||
if ($resolution === 'manual_review') {
|
||||
$manualReviewCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($resolution === 'delete_stale') {
|
||||
if (! $validReplacement && $inactiveSubscription && $team) {
|
||||
$team->subscriptionEnded($inactiveSubscription);
|
||||
}
|
||||
|
||||
$subscription->delete();
|
||||
$fixedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($team) {
|
||||
$team->subscriptionEnded($subscription);
|
||||
} else {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
}
|
||||
$fixedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->fix && count($discrepancies) > 0) {
|
||||
if ($fix && $fixedCount > 0) {
|
||||
send_internal_notification(
|
||||
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
|
||||
"SyncStripeSubscriptions: Fixed {$fixedCount} discrepancies:\n".
|
||||
json_encode($discrepancies, JSON_PRETTY_PRINT)
|
||||
);
|
||||
}
|
||||
@@ -113,7 +157,9 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'discrepancies' => $discrepancies,
|
||||
'resubscribed' => $resubscribed,
|
||||
'errors' => $errors,
|
||||
'fixed' => $this->fix,
|
||||
'fixed' => $fix,
|
||||
'fixed_count' => $fixedCount,
|
||||
'manual_review_count' => $manualReviewCount,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,7 +169,7 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*
|
||||
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
|
||||
*/
|
||||
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
|
||||
private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
|
||||
{
|
||||
try {
|
||||
$customer = $stripe->customers->retrieve($customerId);
|
||||
@@ -177,18 +223,18 @@ class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
|
||||
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
|
||||
{
|
||||
$validIds = [];
|
||||
$fetched = 0;
|
||||
|
||||
foreach (['active', 'past_due'] as $status) {
|
||||
foreach (self::VALID_STRIPE_STATUSES as $status) {
|
||||
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
|
||||
$validIds[] = $sub->id;
|
||||
$fetched++;
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($fetched);
|
||||
$onProgress('fetching', $fetched, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
|
||||
|
||||
public function __construct(?StripeClient $stripe = null)
|
||||
{
|
||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
||||
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ class DeleteUserResources
|
||||
|
||||
return [
|
||||
'applications' => $applications->unique('id'),
|
||||
'databases' => $databases->unique('id'),
|
||||
'databases' => $databases->unique(fn ($database) => $database::class.':'.$database->id),
|
||||
'services' => $services->unique('id'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -137,9 +137,11 @@ class DeleteUserTeams
|
||||
|
||||
// Update the new owner's role to owner
|
||||
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
||||
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
|
||||
|
||||
// Remove the current user from the team
|
||||
$team->members()->detach($this->user->id);
|
||||
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
|
||||
|
||||
$counts['transferred']++;
|
||||
} catch (\Exception $e) {
|
||||
@@ -152,6 +154,7 @@ class DeleteUserTeams
|
||||
foreach ($preview['to_leave'] as $team) {
|
||||
try {
|
||||
$team->members()->detach($this->user->id);
|
||||
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
|
||||
$counts['left']++;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RevokeUserTeamTokens
|
||||
{
|
||||
public static function forUserTeam(User|int $user, int|string $teamId): int
|
||||
{
|
||||
return self::baseQuery()
|
||||
->where('tokenable_id', self::userId($user))
|
||||
->where('team_id', $teamId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
public static function forUser(User|int $user): int
|
||||
{
|
||||
return self::baseQuery()
|
||||
->where('tokenable_id', self::userId($user))
|
||||
->delete();
|
||||
}
|
||||
|
||||
public static function forTeam(int|string $teamId): int
|
||||
{
|
||||
return self::baseQuery()
|
||||
->where('team_id', $teamId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
private static function baseQuery(): Builder
|
||||
{
|
||||
return PersonalAccessToken::query()
|
||||
->where('tokenable_type', User::class);
|
||||
}
|
||||
|
||||
private static function userId(User|int $user): int
|
||||
{
|
||||
return $user instanceof User ? $user->id : $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
|
||||
* plaintext JSON rows written before the column was encrypted, so existing
|
||||
* snapshots keep decoding instead of throwing.
|
||||
*
|
||||
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
|
||||
*/
|
||||
class EncryptedArrayCast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = Crypt::decryptString($value);
|
||||
} catch (DecryptException) {
|
||||
// Legacy plaintext JSON written before this column was encrypted.
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command
|
||||
if ($servers->count() > 0) {
|
||||
foreach ($servers as $server) {
|
||||
echo "Cleanup unreachable server ($server->id) with name $server->name";
|
||||
$server->update([
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
if (isCloud()) {
|
||||
$server->update([
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
} else {
|
||||
$server->forceDisableServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CleanupUnverifiedUsers extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-unverified-users
|
||||
{--yes : Delete eligible users instead of running a dry run}';
|
||||
|
||||
protected $description = 'Delete unverified users without Stripe subscriptions or defined resources';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$eligibleUsers = $this->eligibleUsers();
|
||||
$eligibleCount = $eligibleUsers->count();
|
||||
|
||||
$this->info("Found {$eligibleCount} ".Str::plural('unverified user', $eligibleCount).' eligible for deletion.');
|
||||
$shouldDelete = (bool) $this->option('yes');
|
||||
|
||||
if (! $shouldDelete) {
|
||||
$this->warn('Dry run only. Use --yes to delete eligible users.');
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
if ($eligibleCount > 0) {
|
||||
$progressAction = $shouldDelete ? 'Deleting' : 'Checking';
|
||||
$progressBar = $this->output->createProgressBar($eligibleCount);
|
||||
$progressBar->setFormat("{$progressAction} eligible users: %current%/%max% [%bar%] %percent:3s%%");
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($eligibleUsers->lazyById(100) as $user) {
|
||||
if ($shouldDelete && $user->delete()) {
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
if ($shouldDelete) {
|
||||
$this->info("Deleted {$deletedCount} ".Str::plural('unverified user', $deletedCount).'.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function eligibleUsers(): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('id', '!=', 0)
|
||||
->whereNull('email_verified_at')
|
||||
->whereDoesntHave('teams', fn (Builder $query) => $query->whereKey(0))
|
||||
->whereDoesntHave('teams.subscription')
|
||||
->whereDoesntHave('teams.servers')
|
||||
->whereDoesntHave('teams', function (Builder $query) {
|
||||
$query->whereHas('projects.applications')
|
||||
->orWhereHas('projects.postgresqls')
|
||||
->orWhereHas('projects.redis')
|
||||
->orWhereHas('projects.mongodbs')
|
||||
->orWhereHas('projects.mysqls')
|
||||
->orWhereHas('projects.mariadbs')
|
||||
->orWhereHas('projects.keydbs')
|
||||
->orWhereHas('projects.dragonflies')
|
||||
->orWhereHas('projects.clickhouses')
|
||||
->orWhereHas('projects.services');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class CloudFixSubscription extends Command
|
||||
{
|
||||
@@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripe = app(StripeClient::class);
|
||||
|
||||
if ($this->option('verify-all')) {
|
||||
return $this->verifyAllActiveSubscriptions($stripe);
|
||||
@@ -111,7 +113,7 @@ class CloudFixSubscription extends Command
|
||||
/**
|
||||
* Fix canceled subscriptions in the database
|
||||
*/
|
||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||
private function fixCanceledSubscriptions(StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$checkOne = $this->option('one');
|
||||
@@ -220,7 +222,7 @@ class CloudFixSubscription extends Command
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
$toFixCount++;
|
||||
|
||||
@@ -326,7 +328,7 @@ class CloudFixSubscription extends Command
|
||||
/**
|
||||
* Verify all active subscriptions against Stripe API
|
||||
*/
|
||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||
private function verifyAllActiveSubscriptions(StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$shouldFix = $this->option('fix-verified');
|
||||
@@ -570,7 +572,7 @@ class CloudFixSubscription extends Command
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
$this->error(' → Error: '.$e->getMessage());
|
||||
|
||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||
@@ -730,7 +732,7 @@ class CloudFixSubscription extends Command
|
||||
/**
|
||||
* Search for subscriptions by customer ID
|
||||
*/
|
||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
||||
private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
|
||||
{
|
||||
try {
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
@@ -770,7 +772,7 @@ class CloudFixSubscription extends Command
|
||||
/**
|
||||
* Search for subscriptions by team member emails
|
||||
*/
|
||||
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
|
||||
private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
|
||||
{
|
||||
$this->line(' → Searching by team member emails...');
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExportUsers extends Command
|
||||
{
|
||||
protected $signature = 'cloud:export-users';
|
||||
|
||||
protected $description = 'Export subscribed and unsubscribed verified Coolify Cloud users to separate CSV files';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$backups = Storage::disk('backups');
|
||||
$backups->delete('cloud-users.csv');
|
||||
|
||||
$subscribedPath = $backups->path('cloud-users-subscribed.csv');
|
||||
$unsubscribedPath = $backups->path('cloud-users-unsubscribed.csv');
|
||||
$subscribedOutput = fopen($subscribedPath, 'wb');
|
||||
|
||||
if ($subscribedOutput === false) {
|
||||
$this->error("Unable to open {$subscribedPath} for writing.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$unsubscribedOutput = fopen($unsubscribedPath, 'wb');
|
||||
|
||||
if ($unsubscribedOutput === false) {
|
||||
fclose($subscribedOutput);
|
||||
$this->error("Unable to open {$unsubscribedPath} for writing.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$subscribedCount = 0;
|
||||
$unsubscribedCount = 0;
|
||||
|
||||
try {
|
||||
$header = [
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'lifetime_value_currency',
|
||||
'lifetime_value_amount',
|
||||
'utm_campaign',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'phone',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($subscribedOutput, $header);
|
||||
$this->writeCsvRow($unsubscribedOutput, $header);
|
||||
|
||||
foreach (User::query()
|
||||
->select(['id', 'email', 'name'])
|
||||
->where('id', '!=', 0)
|
||||
->whereNotNull('email_verified_at')
|
||||
->withExists([
|
||||
'teams as is_subscribed' => fn ($query) => $query
|
||||
->whereRelation('subscription', 'stripe_invoice_paid', true),
|
||||
])
|
||||
->lazyById(500) as $user) {
|
||||
$nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: [];
|
||||
[$firstName, $lastName] = array_pad($nameParts, 2, '');
|
||||
|
||||
$row = [
|
||||
$user->email,
|
||||
$firstName,
|
||||
$lastName,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
if ($user->is_subscribed) {
|
||||
$this->writeCsvRow($subscribedOutput, $row);
|
||||
$subscribedCount++;
|
||||
} else {
|
||||
$this->writeCsvRow($unsubscribedOutput, $row);
|
||||
$unsubscribedCount++;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error("Unable to export users: {$exception->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
fclose($subscribedOutput);
|
||||
fclose($unsubscribedOutput);
|
||||
}
|
||||
|
||||
$this->info("Exported {$subscribedCount} subscribed verified users to {$subscribedPath}");
|
||||
$this->info("Exported {$unsubscribedCount} unsubscribed verified users to {$unsubscribedPath}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $output
|
||||
* @param array<int, mixed> $fields
|
||||
*/
|
||||
private function writeCsvRow($output, array $fields): void
|
||||
{
|
||||
if (fputcsv($output, $fields, ',', '"', '') === false) {
|
||||
throw new RuntimeException('Unable to write the CSV file.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Jobs\SyncStripeSubscriptionsJob;
|
||||
use App\Actions\Stripe\SyncStripeSubscriptions as SyncStripeSubscriptionsAction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncStripeSubscriptions extends Command
|
||||
@@ -35,14 +35,18 @@ class SyncStripeSubscriptions extends Command
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$job = new SyncStripeSubscriptionsJob($fix);
|
||||
$fetched = 0;
|
||||
$result = $job->handle(function (int $count) use (&$fetched): void {
|
||||
$fetched = $count;
|
||||
$this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
|
||||
$progressShown = false;
|
||||
$result = SyncStripeSubscriptionsAction::run($fix, function (string $stage, int $current, ?int $total) use (&$progressShown): void {
|
||||
$progressShown = true;
|
||||
$message = match ($stage) {
|
||||
'checking' => " Checking stale subscriptions against Stripe... {$current}/{$total}",
|
||||
default => " Fetching valid subscriptions from Stripe... {$current}",
|
||||
};
|
||||
|
||||
$this->output->write("\r".str_pad($message, 80));
|
||||
});
|
||||
if ($fetched > 0) {
|
||||
$this->output->write("\r".str_repeat(' ', 60)."\r");
|
||||
if ($progressShown) {
|
||||
$this->output->write("\r".str_repeat(' ', 80)."\r");
|
||||
}
|
||||
|
||||
if (isset($result['error'])) {
|
||||
@@ -63,13 +67,22 @@ class SyncStripeSubscriptions extends Command
|
||||
$this->line(" Team ID: {$discrepancy['team_id']}");
|
||||
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
|
||||
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
|
||||
$resolution = match ($discrepancy['resolution']) {
|
||||
'delete_stale' => 'Delete stale local row',
|
||||
'manual_review' => 'Manual review required',
|
||||
default => 'End local subscription',
|
||||
};
|
||||
$this->line(" Resolution: {$resolution}");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if ($fix) {
|
||||
$this->info('All discrepancies have been fixed.');
|
||||
$this->info("Automatic corrections applied: {$result['fixed_count']}");
|
||||
if ($result['manual_review_count'] > 0) {
|
||||
$this->warn("Skipped for manual review: {$result['manual_review_count']}");
|
||||
}
|
||||
} else {
|
||||
$this->comment('Run with --fix to correct these discrepancies.');
|
||||
$this->comment('Run with --fix to apply automatic corrections.');
|
||||
}
|
||||
} else {
|
||||
$this->info('No discrepancies found. All subscriptions are in sync.');
|
||||
@@ -84,6 +97,7 @@ class SyncStripeSubscriptions extends Command
|
||||
$this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}");
|
||||
$this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})");
|
||||
$this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]");
|
||||
$this->line(' Linked to this team: '.($resub['linked_to_team'] ? 'Yes' : 'No'));
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Mail\Message;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Str;
|
||||
use Mail;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
@@ -136,7 +137,7 @@ class Emails extends Command
|
||||
$application = Application::all()->first();
|
||||
$preview = ApplicationPreview::all()->first();
|
||||
if (! $preview) {
|
||||
$preview = ApplicationPreview::forceCreate([
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 1,
|
||||
'pull_request_html_url' => 'http://example.com',
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Generate;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Services extends Command
|
||||
@@ -77,6 +78,7 @@ class Services extends Command
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||
];
|
||||
|
||||
if ($port = $data->get('port')) {
|
||||
@@ -88,9 +90,37 @@ class Services extends Command
|
||||
$payload['envs'] = base64_encode($envFileContent);
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function templateLastUpdatedAt(string $file): ?string
|
||||
{
|
||||
$process = Process::path(base_path())->run([
|
||||
'git',
|
||||
'log',
|
||||
'-1',
|
||||
'--format=%cI',
|
||||
'--',
|
||||
"templates/compose/{$file}",
|
||||
]);
|
||||
|
||||
if ($process->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = trim($process->output());
|
||||
|
||||
return $timestamp === '' ? null : $timestamp;
|
||||
}
|
||||
|
||||
private function generateServiceTemplatesWithFqdn(): void
|
||||
{
|
||||
$serviceTemplatesWithFqdn = collect(array_merge(
|
||||
@@ -147,6 +177,7 @@ class Services extends Command
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||
];
|
||||
|
||||
if ($port = $data->get('port')) {
|
||||
@@ -160,6 +191,14 @@ class Services extends Command
|
||||
$payload['envs'] = base64_encode($modifiedEnvContent);
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@@ -216,6 +255,7 @@ class Services extends Command
|
||||
'category' => $data->get('category'),
|
||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||
'minversion' => $data->get('minversion', '0.0.0'),
|
||||
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||
];
|
||||
|
||||
if ($port = $data->get('port')) {
|
||||
@@ -229,6 +269,14 @@ class Services extends Command
|
||||
$payload['envs'] = $modifiedEnvContent;
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class Init extends Command
|
||||
@@ -161,10 +160,12 @@ class Init extends Command
|
||||
|
||||
private function pullTemplatesFromCDN()
|
||||
{
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
$response = Http::retry(3, 1000, throw: false)
|
||||
->timeout(60)
|
||||
->connectTimeout(10)
|
||||
->get(config('constants.services.official'));
|
||||
if ($response->successful()) {
|
||||
$services = $response->json();
|
||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||
store_service_templates_bundle($response->body());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +254,7 @@ class Init extends Command
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_id' => $database->id,
|
||||
'database_type' => \App\Models\StandalonePostgresql::class,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
'team_id' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
+116
-651
@@ -5,9 +5,12 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Pool;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\select;
|
||||
|
||||
class SyncBunny extends Command
|
||||
{
|
||||
@@ -16,7 +19,7 @@ class SyncBunny extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
|
||||
protected $signature = 'sync:bunny {--bunny}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -25,10 +28,27 @@ class SyncBunny extends Command
|
||||
*/
|
||||
protected $description = 'Sync files to BunnyCDN';
|
||||
|
||||
protected function removeTemporaryDirectory(string $tmpDir): void
|
||||
{
|
||||
$temporaryRoot = realpath(sys_get_temp_dir());
|
||||
$temporaryDirectory = realpath($tmpDir);
|
||||
|
||||
if ($temporaryRoot === false || $temporaryDirectory === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-';
|
||||
if (! str_starts_with($temporaryDirectory, $expectedPrefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File::deleteDirectory($temporaryDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GitHub releases and sync to GitHub repository
|
||||
*/
|
||||
private function syncReleasesToGitHubRepo(): bool
|
||||
private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
|
||||
{
|
||||
$this->info('Fetching releases from GitHub...');
|
||||
try {
|
||||
@@ -43,132 +63,20 @@ class SyncBunny extends Command
|
||||
return false;
|
||||
}
|
||||
|
||||
$releases = $response->json();
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
|
||||
$branchName = 'update-releases-'.$timestamp;
|
||||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
$releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
|
||||
if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||
$this->error('Failed to create temporary releases.json.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
|
||||
|
||||
return false;
|
||||
try {
|
||||
return $this->syncFilesToGitHubRepo($files, $nightly);
|
||||
} finally {
|
||||
@unlink($releasesFile);
|
||||
}
|
||||
|
||||
// Write releases.json
|
||||
$this->info('Writing releases.json...');
|
||||
$releasesPath = "$tmpDir/json/releases.json";
|
||||
$releasesDir = dirname($releasesPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (! is_dir($releasesDir)) {
|
||||
$this->info("Creating directory: $releasesDir");
|
||||
if (! mkdir($releasesDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $releasesDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
$this->error("Failed to write releases.json to: $releasesPath");
|
||||
$this->error('Possible reasons: permission denied or disk full.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage and commit
|
||||
$this->info('Committing changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('Releases are already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
||||
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR Output: '.implode("\n", $output));
|
||||
}
|
||||
$this->info('Total releases synced: '.count($releases));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing releases: '.$e->getMessage());
|
||||
|
||||
@@ -176,193 +84,6 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync both releases.json and versions.json to GitHub repository in one PR
|
||||
*/
|
||||
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||
{
|
||||
$this->info('Syncing releases.json and versions.json to GitHub repository...');
|
||||
try {
|
||||
// 1. Fetch releases from GitHub API
|
||||
$this->info('Fetching releases from GitHub API...');
|
||||
$response = Http::timeout(30)
|
||||
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
||||
'per_page' => 30,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$releases = $response->json();
|
||||
|
||||
// 2. Read versions.json
|
||||
if (! file_exists($versionsLocation)) {
|
||||
$this->error("versions.json not found at: $versionsLocation");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = file_get_contents($versionsLocation);
|
||||
$versionsJson = json_decode($file, true);
|
||||
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
|
||||
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
|
||||
$branchName = 'update-releases-and-versions-'.$timestamp;
|
||||
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||
|
||||
// 3. Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Write releases.json
|
||||
$this->info('Writing releases.json...');
|
||||
$releasesPath = "$tmpDir/json/releases.json";
|
||||
$releasesDir = dirname($releasesPath);
|
||||
|
||||
if (! is_dir($releasesDir)) {
|
||||
if (! mkdir($releasesDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $releasesDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
|
||||
$this->error("Failed to write releases.json to: $releasesPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. Write versions.json
|
||||
$this->info('Writing versions.json...');
|
||||
$versionsPath = "$tmpDir/$versionsTargetPath";
|
||||
$versionsDir = dirname($versionsPath);
|
||||
|
||||
if (! is_dir($versionsDir)) {
|
||||
if (! mkdir($versionsDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $versionsDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
|
||||
$this->error("Failed to write versions.json to: $versionsPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 7. Stage both files
|
||||
$this->info('Staging changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 8. Check for changes
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('Both files are already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 9. Commit changes
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 10. Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 11. Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// 12. Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info("Version synced: $actualVersion");
|
||||
$this->info('Total releases synced: '.count($releases));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing to GitHub: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
|
||||
*/
|
||||
@@ -372,13 +93,13 @@ class SyncBunny extends Command
|
||||
$this->info("Syncing $envLabel files to GitHub repository...");
|
||||
try {
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
|
||||
$tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
|
||||
$branchName = 'update-files-'.$timestamp;
|
||||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
$this->info('Cloning coollabs-cdn repository...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
@@ -391,7 +112,7 @@ class SyncBunny extends Command
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -411,7 +132,7 @@ class SyncBunny extends Command
|
||||
if (! is_dir($destDir)) {
|
||||
if (! mkdir($destDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $destDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -419,7 +140,7 @@ class SyncBunny extends Command
|
||||
|
||||
if (copy($sourceFile, $destPath) === false) {
|
||||
$this->error("Failed to copy $sourceFile to $destPath");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -430,7 +151,7 @@ class SyncBunny extends Command
|
||||
|
||||
if (empty($copiedFiles)) {
|
||||
$this->warn('No files were copied. Nothing to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -442,25 +163,26 @@ class SyncBunny extends Command
|
||||
exec($stageCmd, $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||
$changedFiles = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->error('Failed to check changed files: '.implode("\n", $changedFiles));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$changedFiles = array_values(array_filter($changedFiles));
|
||||
if (empty($changedFiles)) {
|
||||
$this->info('All files are already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -471,7 +193,7 @@ class SyncBunny extends Command
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -482,7 +204,7 @@ class SyncBunny extends Command
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -490,14 +212,14 @@ class SyncBunny extends Command
|
||||
// Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
|
||||
$fileList = implode("\n- ", $copiedFiles);
|
||||
$fileList = implode("\n- ", $changedFiles);
|
||||
$prBody = "Automated update of $envLabel files:\n- $fileList";
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
$output = [];
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
$this->removeTemporaryDirectory($tmpDir);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
@@ -509,7 +231,7 @@ class SyncBunny extends Command
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info('Files synced: '.count($copiedFiles));
|
||||
$this->info('Files synced: '.count($changedFiles));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
@@ -519,167 +241,21 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync versions.json to GitHub repository via PR
|
||||
*/
|
||||
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||
{
|
||||
$this->info('Syncing versions.json to GitHub repository...');
|
||||
try {
|
||||
if (! file_exists($versionsLocation)) {
|
||||
$this->error("versions.json not found at: $versionsLocation");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = file_get_contents($versionsLocation);
|
||||
$json = json_decode($file, true);
|
||||
$actualVersion = data_get($json, 'coolify.v4.version');
|
||||
|
||||
$timestamp = time();
|
||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
|
||||
$branchName = 'update-versions-'.$timestamp;
|
||||
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||
|
||||
// Clone the repository
|
||||
$this->info('Cloning coolify-cdn repository...');
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create feature branch
|
||||
$this->info('Creating feature branch...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write versions.json
|
||||
$this->info('Writing versions.json...');
|
||||
$versionsPath = "$tmpDir/$targetPath";
|
||||
$versionsDir = dirname($versionsPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (! is_dir($versionsDir)) {
|
||||
$this->info("Creating directory: $versionsDir");
|
||||
if (! mkdir($versionsDir, 0755, true)) {
|
||||
$this->error("Failed to create directory: $versionsDir");
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
$this->error("Failed to write versions.json to: $versionsPath");
|
||||
$this->error('Possible reasons: permission denied or disk full.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage and commit
|
||||
$this->info('Committing changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Checking for changes...');
|
||||
$statusOutput = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty(array_filter($statusOutput))) {
|
||||
$this->info('versions.json is already up to date. No changes to commit.');
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
$this->info('Pushing branch to remote...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create pull request
|
||||
$this->info('Creating pull request...');
|
||||
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
|
||||
$output = [];
|
||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||
exec($prCommand, $output, $returnCode);
|
||||
|
||||
// Clean up
|
||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->info('Pull request created successfully!');
|
||||
if (! empty($output)) {
|
||||
$this->info('PR URL: '.implode("\n", $output));
|
||||
}
|
||||
$this->info("Version synced: $actualVersion");
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error syncing versions.json: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$that = $this;
|
||||
$only_template = $this->option('templates');
|
||||
$only_version = $this->option('release');
|
||||
$only_github_releases = $this->option('github-releases');
|
||||
$only_github_versions = $this->option('github-versions');
|
||||
$nightly = $this->option('nightly');
|
||||
$only_bunny = $this->option('bunny');
|
||||
$nightly = select(
|
||||
label: 'Which environment would you like to sync?',
|
||||
options: [
|
||||
'production' => 'Production',
|
||||
'nightly' => 'Nightly',
|
||||
],
|
||||
default: 'production',
|
||||
) === 'nightly';
|
||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||
$bunny_cdn_path = 'coolify';
|
||||
$bunny_cdn_storage_name = 'coolcdn';
|
||||
@@ -690,6 +266,7 @@ class SyncBunny extends Command
|
||||
$compose_file_prod = 'docker-compose.prod.yml';
|
||||
$install_script = 'install.sh';
|
||||
$upgrade_script = 'upgrade.sh';
|
||||
$upgrade_postgres_script = 'upgrade-postgres.sh';
|
||||
$production_env = '.env.production';
|
||||
$service_template = config('constants.services.file_name');
|
||||
$versions = 'versions.json';
|
||||
@@ -698,7 +275,9 @@ class SyncBunny extends Command
|
||||
$compose_file_prod_location = "$parent_dir/$compose_file_prod";
|
||||
$install_script_location = "$parent_dir/scripts/install.sh";
|
||||
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
|
||||
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
|
||||
$production_env_location = "$parent_dir/.env.production";
|
||||
$service_template_location = "$parent_dir/templates/$service_template";
|
||||
$versions_location = "$parent_dir/$versions";
|
||||
|
||||
PendingRequest::macro('storage', function ($fileName) use ($that) {
|
||||
@@ -733,43 +312,26 @@ class SyncBunny extends Command
|
||||
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
|
||||
$production_env_location = "$parent_dir/other/nightly/$production_env";
|
||||
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
|
||||
$upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
|
||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||
}
|
||||
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
||||
if ($only_bunny) {
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
|
||||
$this->info("About to sync $envLabel files to BunnyCDN.");
|
||||
$this->newLine();
|
||||
|
||||
// Build file mapping for diff
|
||||
if ($nightly) {
|
||||
$fileMapping = [
|
||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/nightly/.env.production',
|
||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
||||
$install_script_location => 'scripts/nightly/install.sh',
|
||||
];
|
||||
} else {
|
||||
$fileMapping = [
|
||||
$compose_file_location => 'docker/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/.env.production',
|
||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
||||
$install_script_location => 'scripts/install.sh',
|
||||
];
|
||||
}
|
||||
|
||||
// BunnyCDN file mapping (local file => CDN URL path)
|
||||
$bunnyFileMapping = [
|
||||
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
|
||||
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
|
||||
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
|
||||
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
|
||||
$upgrade_postgres_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script",
|
||||
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
|
||||
];
|
||||
|
||||
$diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
|
||||
$diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time();
|
||||
@mkdir($diffTmpDir, 0755, true);
|
||||
$hasChanges = false;
|
||||
|
||||
@@ -812,45 +374,7 @@ class SyncBunny extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Diff against GitHub coolify-cdn repo
|
||||
$this->newLine();
|
||||
$this->info('Fetching coolify-cdn repo to compare...');
|
||||
$output = [];
|
||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
|
||||
|
||||
if ($returnCode === 0) {
|
||||
foreach ($fileMapping as $localFile => $cdnPath) {
|
||||
$remotePath = "$diffTmpDir/repo/$cdnPath";
|
||||
if (! file_exists($localFile)) {
|
||||
continue;
|
||||
}
|
||||
if (! file_exists($remotePath)) {
|
||||
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
|
||||
$hasChanges = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$diffOutput = [];
|
||||
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
|
||||
if ($diffCode !== 0) {
|
||||
$hasChanges = true;
|
||||
$this->newLine();
|
||||
$this->info("--- GitHub: $cdnPath");
|
||||
$this->info("+++ Local: $cdnPath");
|
||||
foreach ($diffOutput as $line) {
|
||||
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
||||
continue;
|
||||
}
|
||||
$this->line($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->warn('Could not fetch coolify-cdn repo for diff.');
|
||||
}
|
||||
|
||||
exec('rm -rf '.escapeshellarg($diffTmpDir));
|
||||
$this->removeTemporaryDirectory($diffTmpDir);
|
||||
|
||||
if (! $hasChanges) {
|
||||
$this->newLine();
|
||||
@@ -866,89 +390,55 @@ class SyncBunny extends Command
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($only_template) {
|
||||
$this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
|
||||
$confirmed = confirm('Are you sure you want to sync?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
$pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"),
|
||||
]);
|
||||
$this->info('Service template uploaded & purged...');
|
||||
|
||||
return;
|
||||
} elseif ($only_version) {
|
||||
if ($nightly) {
|
||||
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
|
||||
} else {
|
||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
|
||||
}
|
||||
$file = file_get_contents($versions_location);
|
||||
$json = json_decode($file, true);
|
||||
$actual_version = data_get($json, 'coolify.v4.version');
|
||||
|
||||
$this->info("Version: {$actual_version}");
|
||||
$this->info('This will:');
|
||||
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
|
||||
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
|
||||
$this->newLine();
|
||||
|
||||
$confirmed = confirm('Are you sure you want to proceed?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
|
||||
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||
]);
|
||||
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
||||
$this->newLine();
|
||||
|
||||
// 2. Create GitHub PR with both releases.json and versions.json
|
||||
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
|
||||
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
|
||||
if ($githubSuccess) {
|
||||
$this->info('✓ GitHub PR created successfully with both files');
|
||||
} else {
|
||||
$this->error('✗ Failed to create GitHub PR');
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->info('=== Summary ===');
|
||||
$this->info('BunnyCDN sync: ✓ Complete');
|
||||
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
|
||||
|
||||
return;
|
||||
} elseif ($only_github_releases) {
|
||||
$this->info('About to sync GitHub releases to GitHub repository.');
|
||||
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync releases to GitHub repository
|
||||
$this->syncReleasesToGitHubRepo();
|
||||
|
||||
return;
|
||||
} elseif ($only_github_versions) {
|
||||
if (! $only_bunny) {
|
||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||
$file = file_get_contents($versions_location);
|
||||
$json = json_decode($file, true);
|
||||
$actual_version = data_get($json, 'coolify.v4.version');
|
||||
$this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
|
||||
|
||||
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
|
||||
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
|
||||
if (! $confirmed) {
|
||||
return;
|
||||
if ($nightly) {
|
||||
$files = [
|
||||
$versions_location => 'json/coolify/nightly/versions.json',
|
||||
$compose_file_location => 'json/coolify/nightly/docker-compose.yml',
|
||||
$compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
|
||||
$production_env_location => 'json/coolify/nightly/.env.production',
|
||||
$install_script_location => 'json/coolify/nightly/install.sh',
|
||||
$upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
|
||||
$upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
|
||||
$service_template_location => 'json/coolify/nightly/service-templates-latest.json',
|
||||
];
|
||||
} else {
|
||||
$files = [
|
||||
$versions_location => 'json/coolify/versions.json',
|
||||
$compose_file_location => 'json/coolify/docker-compose.yml',
|
||||
$compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
|
||||
$production_env_location => 'json/coolify/.env.production',
|
||||
$install_script_location => 'json/coolify/install.sh',
|
||||
$upgrade_script_location => 'json/coolify/upgrade.sh',
|
||||
$upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
|
||||
$service_template_location => 'json/coolify/service-templates-latest.json',
|
||||
];
|
||||
}
|
||||
|
||||
// Sync versions.json to GitHub repository
|
||||
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
|
||||
$releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
|
||||
$options = [$releasesTarget, ...array_values($files)];
|
||||
$selectedFiles = multiselect(
|
||||
label: 'Which files would you like to sync?',
|
||||
options: $options,
|
||||
default: $options,
|
||||
required: true,
|
||||
scroll: count($options),
|
||||
);
|
||||
|
||||
$includeReleases = in_array($releasesTarget, $selectedFiles, true);
|
||||
$files = array_filter(
|
||||
$files,
|
||||
fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
|
||||
);
|
||||
|
||||
if ($includeReleases) {
|
||||
$this->syncReleasesToGitHubRepo($files, $nightly);
|
||||
} else {
|
||||
$this->syncFilesToGitHubRepo($files, $nightly);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -958,6 +448,7 @@ class SyncBunny extends Command
|
||||
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
|
||||
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
|
||||
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
|
||||
$pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
|
||||
]);
|
||||
Http::pool(fn (Pool $pool) => [
|
||||
@@ -965,36 +456,10 @@ class SyncBunny extends Command
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
|
||||
]);
|
||||
$this->info('All files uploaded & purged to BunnyCDN.');
|
||||
$this->newLine();
|
||||
|
||||
// Sync files to GitHub CDN repository via PR
|
||||
$this->info('Creating GitHub PR for coolify-cdn repository...');
|
||||
if ($nightly) {
|
||||
$files = [
|
||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/nightly/.env.production',
|
||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
||||
$install_script_location => 'scripts/nightly/install.sh',
|
||||
];
|
||||
} else {
|
||||
$files = [
|
||||
$compose_file_location => 'docker/docker-compose.yml',
|
||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
||||
$production_env_location => 'environment/.env.production',
|
||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
||||
$install_script_location => 'scripts/install.sh',
|
||||
];
|
||||
}
|
||||
|
||||
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
|
||||
$this->newLine();
|
||||
$this->info('=== Summary ===');
|
||||
$this->info('BunnyCDN sync: Complete');
|
||||
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
|
||||
public function handle()
|
||||
{
|
||||
$date = $this->option('date') ?: now()->format('Y-m-d');
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$logPaths = $this->getLogPaths($date);
|
||||
|
||||
if (empty($logPaths)) {
|
||||
@@ -49,17 +54,19 @@ class ViewScheduledLogs extends Command
|
||||
$this->line('');
|
||||
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
$logPath = escapeshellarg($logPaths[0]);
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPath} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -f {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - use multitail or tail with process substitution
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -f {$logPathsStr}");
|
||||
}
|
||||
@@ -68,20 +75,23 @@ class ViewScheduledLogs extends Command
|
||||
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
|
||||
$this->line('');
|
||||
|
||||
$escapedLines = escapeshellarg((string) $lines);
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
$logPath = escapeshellarg($logPaths[0]);
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPath}");
|
||||
passthru("tail -n {$escapedLines} {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - concatenate and sort by timestamp
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort");
|
||||
passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
@@ -39,8 +41,13 @@ class Kernel extends ConsoleKernel
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
|
||||
->name('cleanup:ssh-mux')
|
||||
->hourly()
|
||||
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
|
||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
@@ -75,7 +82,7 @@ class Kernel extends ConsoleKernel
|
||||
// Scheduled Jobs (Backups & Tasks)
|
||||
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ enum BuildPackTypes: string
|
||||
case STATIC = 'static';
|
||||
case DOCKERFILE = 'dockerfile';
|
||||
case DOCKERCOMPOSE = 'dockercompose';
|
||||
case RAILPACK = 'railpack';
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Exceptions;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Psr\Log\LogLevel;
|
||||
use RuntimeException;
|
||||
use Sentry\Laravel\Integration;
|
||||
use Sentry\State\Scope;
|
||||
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
|
||||
/**
|
||||
* A list of exception types with their corresponding custom log levels.
|
||||
*
|
||||
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
|
||||
* @var array<class-string<Throwable>, LogLevel::*>
|
||||
*/
|
||||
protected $levels = [
|
||||
//
|
||||
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
|
||||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var array<int, class-string<\Throwable>>
|
||||
* @var array<int, class-string<Throwable>>
|
||||
*/
|
||||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
|
||||
protected function unauthenticated($request, AuthenticationException $exception)
|
||||
{
|
||||
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
|
||||
if ($request->is('api/*')) {
|
||||
auditLog('api.auth.unauthenticated', [
|
||||
'reason' => $exception->getMessage(),
|
||||
'guards' => $exception->guards(),
|
||||
], 'warning');
|
||||
}
|
||||
|
||||
return response()->json(['message' => $exception->getMessage()], 401);
|
||||
}
|
||||
|
||||
@@ -60,9 +69,17 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function render($request, Throwable $e)
|
||||
{
|
||||
// Handle authorization exceptions for API routes
|
||||
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
|
||||
// Handle authorization exceptions for API routes. Exceptions carrying
|
||||
// an explicit status (e.g. denyAsNotFound) keep it via parent::render.
|
||||
if ($e instanceof AuthorizationException && ! $e->hasStatus()) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
if ($request->is('api/*')) {
|
||||
auditLog('api.auth.policy_denied', [
|
||||
'reason' => $e->getMessage(),
|
||||
'route' => $request->route()?->getName() ?? $request->path(),
|
||||
], 'warning');
|
||||
}
|
||||
|
||||
// Get the custom message from the policy if available
|
||||
$message = $e->getMessage();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Helpers;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -12,15 +13,13 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SshMultiplexingHelper
|
||||
{
|
||||
public static function serverSshConfiguration(Server $server)
|
||||
public static function serverSshConfiguration(Server $server): array
|
||||
{
|
||||
$privateKey = PrivateKey::findOrFail($server->private_key_id);
|
||||
$sshKeyLocation = $privateKey->getKeyLocation();
|
||||
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
|
||||
|
||||
return [
|
||||
'sshKeyLocation' => $sshKeyLocation,
|
||||
'muxFilename' => $muxFilename,
|
||||
'sshKeyLocation' => $privateKey->getKeyLocation(),
|
||||
'muxFilename' => self::muxSocket($server),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,40 +29,39 @@ class SshMultiplexingHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
|
||||
// Check if connection exists
|
||||
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$checkCommand .= self::escapedUserAtHost($server);
|
||||
$process = Process::run($checkCommand);
|
||||
|
||||
if ($process->exitCode() !== 0) {
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
if (self::connectionIsReusable($server)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Connection exists, ensure we have metadata for age tracking
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
// Existing connection but no metadata, store current time as fallback
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
try {
|
||||
return Cache::lock(
|
||||
self::connectionLockKey($server),
|
||||
config('constants.ssh.mux_lock_ttl')
|
||||
)->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
|
||||
if (self::connectionIsReusable($server)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Connection exists, check if it needs refresh due to age
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
if (self::masterConnectionExists($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
// Perform health check if enabled
|
||||
if (config('constants.ssh.mux_health_check_enabled')) {
|
||||
if (! self::isConnectionHealthy($server)) {
|
||||
return self::refreshMultiplexedConnection($server);
|
||||
}
|
||||
}
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
]);
|
||||
|
||||
return true;
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function establishNewMultiplexedConnection(Server $server): bool
|
||||
@@ -71,86 +69,113 @@ class SshMultiplexingHelper
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$connectionTimeout = config('constants.ssh.connection_timeout');
|
||||
$connectionTimeout = self::getConnectionTimeout($server);
|
||||
$serverInterval = config('constants.ssh.server_interval');
|
||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||
|
||||
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
||||
$establishCommand .= self::escapedUserAtHost($server);
|
||||
|
||||
$establishProcess = Process::run($establishCommand);
|
||||
if ($establishProcess->exitCode() !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store connection metadata for tracking
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function removeMuxFile(Server $server)
|
||||
public static function removeMuxFile(Server $server): void
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
|
||||
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$closeCommand .= self::escapedUserAtHost($server);
|
||||
Process::run($closeCommand);
|
||||
|
||||
// Clear connection metadata from cache
|
||||
Process::run(self::muxControlCommand($server, 'exit'));
|
||||
self::clearConnectionMetadata($server);
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest)
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest): string
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
|
||||
|
||||
$timeout = config('constants.ssh.command_timeout');
|
||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||
|
||||
$scp_command = "timeout $timeout scp ";
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= '-6 ';
|
||||
$scpCommand .= '-6 ';
|
||||
}
|
||||
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
$scpCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// Continue without multiplexing
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
||||
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
|
||||
// Upload: local source -> remote dest
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
||||
} else {
|
||||
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
|
||||
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
return $scp_command;
|
||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
|
||||
/**
|
||||
* Build an SCP command that downloads a remote file onto the Coolify host.
|
||||
*/
|
||||
public static function generateScpDownloadCommand(Server $server, string $remoteSource, string $localDest): string
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
|
||||
|
||||
if ($server->isIpv6()) {
|
||||
$scpCommand .= '-6 ';
|
||||
}
|
||||
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scpCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP download, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
|
||||
// Download: remote source -> local dest
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
return $scpCommand.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
if ($server->settings->force_disabled) {
|
||||
throw new \RuntimeException('Server is disabled.');
|
||||
@@ -161,40 +186,139 @@ class SshMultiplexingHelper
|
||||
|
||||
self::validateSshKey($server->privateKey);
|
||||
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
||||
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||
|
||||
$timeout = config('constants.ssh.command_timeout');
|
||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||
|
||||
$ssh_command = "timeout $timeout ssh ";
|
||||
|
||||
$multiplexingSuccessful = false;
|
||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
||||
if ($multiplexingSuccessful) {
|
||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$sshCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Continue without multiplexing
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||
$sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||
}
|
||||
|
||||
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
|
||||
$sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||
|
||||
$delimiter = Hash::make($command);
|
||||
$delimiter = base64_encode($delimiter);
|
||||
$delimiter = base64_encode(Hash::make($command));
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
|
||||
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
return $ssh_command;
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
return is_numeric($timeout) && (int) $timeout > 0
|
||||
? (int) $timeout
|
||||
: (int) config('constants.ssh.connection_timeout');
|
||||
}
|
||||
|
||||
public static function isConnectionHealthy(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||
|
||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
|
||||
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
}
|
||||
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
self::removeMuxFile($server);
|
||||
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
private static function connectionLockKey(Server $server): string
|
||||
{
|
||||
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
||||
}
|
||||
|
||||
private static function masterConnectionExists(Server $server): bool
|
||||
{
|
||||
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
|
||||
}
|
||||
|
||||
private static function connectionIsReusable(Server $server): bool
|
||||
{
|
||||
if (! self::masterConnectionExists($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function muxControlCommand(Server $server, string $operation): string
|
||||
{
|
||||
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
return $command.self::escapedUserAtHost($server);
|
||||
}
|
||||
|
||||
private static function multiplexingOptions(Server $server): string
|
||||
{
|
||||
return '-o ControlMaster=auto '
|
||||
.'-o ControlPath='.self::muxSocket($server).' '
|
||||
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
|
||||
}
|
||||
|
||||
private static function muxSocket(Server $server): string
|
||||
{
|
||||
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
|
||||
}
|
||||
|
||||
private static function escapedUserAtHost(Server $server): string
|
||||
@@ -231,7 +355,6 @@ class SshMultiplexingHelper
|
||||
$privateKey->storeInFileSystem();
|
||||
}
|
||||
|
||||
// Ensure correct permissions (SSH requires 0600)
|
||||
if (file_exists($keyLocation)) {
|
||||
$currentPerms = fileperms($keyLocation) & 0777;
|
||||
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
|
||||
@@ -253,90 +376,20 @@ class SshMultiplexingHelper
|
||||
.'-o RequestTTY=no '
|
||||
.'-o LogLevel=ERROR ';
|
||||
|
||||
// Bruh
|
||||
if ($isScp) {
|
||||
$options .= '-P '.escapeshellarg((string) $server->port).' ';
|
||||
} else {
|
||||
$options .= '-p '.escapeshellarg((string) $server->port).' ';
|
||||
return $options.'-P '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
return $options;
|
||||
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the multiplexed connection is healthy by running a test command
|
||||
*/
|
||||
public static function isConnectionHealthy(Server $server): bool
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||
|
||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
|
||||
$process = Process::run($healthCommand);
|
||||
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
|
||||
return $isHealthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connection has exceeded its maximum age
|
||||
*/
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the age of the current connection in seconds
|
||||
*/
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
$connectionTime = Cache::get($cacheKey);
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a multiplexed connection by closing and re-establishing it
|
||||
*/
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
// Close existing connection
|
||||
self::removeMuxFile($server);
|
||||
|
||||
// Establish new connection
|
||||
return self::establishNewMultiplexedConnection($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store connection metadata when a new connection is established
|
||||
*/
|
||||
private static function storeConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
|
||||
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear connection metadata from cache
|
||||
*/
|
||||
private static function clearConnectionMetadata(Server $server): void
|
||||
{
|
||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
||||
Cache::forget($cacheKey);
|
||||
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class CloudInitScriptsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(CloudInitScript $script): array
|
||||
{
|
||||
$script->makeHidden(['id', 'team_id']);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$script->makeVisible(['script']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($script)->all();
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Cloud-init Scripts',
|
||||
description: 'List all cloud-init scripts for the authenticated team.',
|
||||
path: '/cloud-init-scripts',
|
||||
operationId: 'list-cloud-init-scripts',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Cloud-init Scripts'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloud-init scripts for the team.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('viewAny', CloudInitScript::class);
|
||||
|
||||
$scripts = CloudInitScript::where('team_id', $teamId)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn (CloudInitScript $script) => $this->removeSensitiveData($script));
|
||||
|
||||
return response()->json($scripts);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Cloud-init Script',
|
||||
description: 'Create a new cloud-init script for the authenticated team.',
|
||||
path: '/cloud-init-scripts',
|
||||
operationId: 'create-cloud-init-script',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Cloud-init Scripts'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name', 'script'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'script', type: 'string', description: 'Bash script (#!) or cloud-config YAML.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Cloud-init script created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', CloudInitScript::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'script' => ['required', 'string', new ValidCloudInitYaml],
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), ['name', 'script']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$script = CloudInitScript::create([
|
||||
'team_id' => $teamId,
|
||||
'name' => $request->string('name')->toString(),
|
||||
'script' => $request->string('script')->toString(),
|
||||
]);
|
||||
|
||||
auditLog('api.cloud_init_script.created', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_init_script_uuid' => $script->uuid,
|
||||
'cloud_init_script_name' => $script->name,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($script), 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Cloud-init Script',
|
||||
description: 'Get a cloud-init script by UUID.',
|
||||
path: '/cloud-init-scripts/{uuid}',
|
||||
operationId: 'get-cloud-init-script-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Cloud-init Scripts'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloud-init script.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $script) {
|
||||
return response()->json(['message' => 'Cloud-init script not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $script);
|
||||
|
||||
return response()->json($this->removeSensitiveData($script));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Cloud-init Script',
|
||||
description: 'Update a cloud-init script by UUID.',
|
||||
path: '/cloud-init-scripts/{uuid}',
|
||||
operationId: 'update-cloud-init-script-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Cloud-init Scripts'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'script', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloud-init script updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if ($request->all() === []) {
|
||||
return response()->json(['message' => 'At least one field must be provided.'], 422);
|
||||
}
|
||||
|
||||
$script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $script) {
|
||||
return response()->json(['message' => 'Cloud-init script not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $script);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'script' => ['string', new ValidCloudInitYaml],
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), ['name', 'script']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$script->update($request->only(['name', 'script']));
|
||||
|
||||
auditLog('api.cloud_init_script.updated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_init_script_uuid' => $script->uuid,
|
||||
'cloud_init_script_name' => $script->name,
|
||||
'changed_fields' => array_values(array_intersect(['name', 'script'], array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($script->fresh()));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Cloud-init Script',
|
||||
description: 'Delete a cloud-init script by UUID.',
|
||||
path: '/cloud-init-scripts/{uuid}',
|
||||
operationId: 'delete-cloud-init-script-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Cloud-init Scripts'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloud-init script deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $script) {
|
||||
return response()->json(['message' => 'Cloud-init script not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $script);
|
||||
|
||||
$uuid = $script->uuid;
|
||||
$name = $script->name;
|
||||
$script->delete();
|
||||
|
||||
auditLog('api.cloud_init_script.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_init_script_uuid' => $uuid,
|
||||
'cloud_init_script_name' => $name,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Cloud-init script deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -15,9 +16,14 @@ class CloudProviderTokensController extends Controller
|
||||
{
|
||||
$token->makeHidden([
|
||||
'id',
|
||||
'token',
|
||||
]);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$token->makeVisible([
|
||||
'token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($token);
|
||||
}
|
||||
|
||||
@@ -36,6 +42,9 @@ class CloudProviderTokensController extends Controller
|
||||
'digitalocean' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||
'vultr' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.vultr.com/v2/account'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
@@ -81,7 +90,7 @@ class CloudProviderTokensController extends Controller
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
@@ -176,6 +185,7 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($token)) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return response()->json($this->removeSensitiveData($token));
|
||||
}
|
||||
@@ -198,7 +208,7 @@ class CloudProviderTokensController extends Controller
|
||||
type: 'object',
|
||||
required: ['provider', 'token', 'name'],
|
||||
properties: [
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||
],
|
||||
@@ -242,9 +252,10 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [CloudProviderToken::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -252,7 +263,7 @@ class CloudProviderTokensController extends Controller
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
@@ -286,6 +297,13 @@ class CloudProviderTokensController extends Controller
|
||||
'name' => $body['name'],
|
||||
]);
|
||||
|
||||
auditLog('api.cloud_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $cloudProviderToken->name,
|
||||
'provider' => $cloudProviderToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $cloudProviderToken->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -355,7 +373,7 @@ class CloudProviderTokensController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -386,9 +404,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $token);
|
||||
|
||||
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
auditLog('api.cloud_token.updated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $token->uuid,
|
||||
'cloud_token_name' => $token->name,
|
||||
'provider' => $token->provider,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
@@ -459,13 +486,24 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->hasServers()) {
|
||||
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||
}
|
||||
|
||||
$tokenUuid = $token->uuid;
|
||||
$tokenName = $token->name;
|
||||
$tokenProvider = $token->provider;
|
||||
$token->delete();
|
||||
|
||||
auditLog('api.cloud_token.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $tokenUuid,
|
||||
'cloud_token_name' => $tokenName,
|
||||
'provider' => $tokenProvider,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||
}
|
||||
|
||||
@@ -519,9 +557,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $cloudToken) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $cloudToken);
|
||||
|
||||
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||
|
||||
auditLog('api.cloud_token.validated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudToken->uuid,
|
||||
'cloud_token_name' => $cloudToken->name,
|
||||
'provider' => $cloudToken->provider,
|
||||
'valid' => $validation['valid'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
trait HandlesTagsApi
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
|
||||
}
|
||||
|
||||
public function createTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->has('tag_name') && $request->has('tag_names')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'tag_name' => 'required_without:tag_names|string',
|
||||
'tag_names' => 'required_without:tag_name|array|min:1',
|
||||
'tag_names.*' => 'string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->attachTagsToResource($resource, $tagNames, $teamId);
|
||||
|
||||
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function deleteTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $resource->tags()->whereKey($tag->id)->exists()) {
|
||||
return response()->json(['message' => 'Tag not found on resource.'], 404);
|
||||
}
|
||||
|
||||
$resource->tags()->detach($tag->id);
|
||||
$tag->deleteIfOrphaned();
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
|
||||
{
|
||||
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
|
||||
if (mb_strlen($tagName) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = Tag::query()->createOrFirst([
|
||||
'team_id' => $teamId,
|
||||
'name' => $tagName,
|
||||
]);
|
||||
|
||||
$resource->tags()->syncWithoutDetaching([$tag->id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateTagsParameter(Request $request): ?JsonResponse
|
||||
{
|
||||
if (! $request->has('tags')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->input('tags', []));
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tags' => $tagNames]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizeTagNames(array $tagNames): array
|
||||
{
|
||||
return collect($tagNames)
|
||||
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ use App\Models\Tag;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class DeployController extends Controller
|
||||
{
|
||||
@@ -25,6 +24,10 @@ class DeployController extends Controller
|
||||
$deployment->makeHidden([
|
||||
'logs',
|
||||
]);
|
||||
} else {
|
||||
$deployment->makeVisible([
|
||||
'logs',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($deployment);
|
||||
@@ -281,6 +284,14 @@ class DeployController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
auditLog('api.deployment.cancelled', [
|
||||
'team_id' => $teamId,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'application_id' => $application?->id,
|
||||
'application_uuid' => $application?->uuid,
|
||||
'server_id' => $deployment->server_id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment cancelled successfully.',
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
@@ -293,9 +304,9 @@ class DeployController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Deploy',
|
||||
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
|
||||
description: 'Deploy by tag or UUID using query parameters or a JSON body.',
|
||||
path: '/deploy',
|
||||
operationId: 'deploy-by-tag-or-uuid',
|
||||
security: [
|
||||
@@ -358,7 +369,7 @@ class DeployController extends Controller
|
||||
|
||||
$uuids = $request->input('uuid');
|
||||
$tags = $request->input('tag');
|
||||
$force = $request->input('force') ?? false;
|
||||
$force = $request->boolean('force');
|
||||
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
|
||||
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
|
||||
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
|
||||
@@ -418,7 +429,7 @@ class DeployController extends Controller
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
} else {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
|
||||
}
|
||||
@@ -464,7 +475,7 @@ class DeployController extends Controller
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
}
|
||||
$message = $message->merge($return_message);
|
||||
}
|
||||
@@ -503,7 +514,7 @@ class DeployController extends Controller
|
||||
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
|
||||
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -518,6 +529,14 @@ class DeployController extends Controller
|
||||
$message = $result['message'];
|
||||
} else {
|
||||
$message = "Application {$resource->name} deployment queued.";
|
||||
auditLog('api.deployment.triggered', [
|
||||
'resource_type' => 'application',
|
||||
'application_uuid' => $resource->uuid,
|
||||
'application_name' => $resource->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'force_rebuild' => $force,
|
||||
'pull_request_id' => $pr,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
case Service::class:
|
||||
@@ -529,6 +548,10 @@ class DeployController extends Controller
|
||||
}
|
||||
StartService::run($resource);
|
||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||
auditLog('api.service.deployed', [
|
||||
'service_uuid' => $resource->uuid,
|
||||
'service_name' => $resource->name,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
// Database resource - check authorization
|
||||
@@ -543,6 +566,11 @@ class DeployController extends Controller
|
||||
$resource->save();
|
||||
|
||||
$message = "Database {$resource->name} started.";
|
||||
auditLog('api.database.started', [
|
||||
'database_uuid' => $resource->uuid,
|
||||
'database_name' => $resource->name,
|
||||
'database_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -674,6 +702,9 @@ class DeployController extends Controller
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$deployments = $application->deployments($skip, $take);
|
||||
if ($request->attributes->get('can_read_sensitive', false) === true) {
|
||||
$deployments['deployments']->each->makeVisible(['logs']);
|
||||
}
|
||||
|
||||
return response()->json($deployments);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $destination->uuid,
|
||||
'name' => $destination->name,
|
||||
'network' => $destination->network,
|
||||
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
||||
'server_uuid' => $destination->server?->uuid,
|
||||
'created_at' => $destination->created_at,
|
||||
'updated_at' => $destination->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calling token's team id, or return an invalid-token response.
|
||||
*/
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
|
||||
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
|
||||
* controller works on Coolify versions that pre-date that scope being added
|
||||
* to the destination models (e.g. 4.0.0-beta.470).
|
||||
*/
|
||||
private function teamScopedDockers(int $teamId): array
|
||||
{
|
||||
return [
|
||||
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
];
|
||||
}
|
||||
|
||||
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
|
||||
{
|
||||
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
|
||||
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List destinations',
|
||||
description: 'List all Docker network destinations for the authenticated team.',
|
||||
path: '/destinations',
|
||||
operationId: 'list-destinations',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destinations for the authenticated team.',
|
||||
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
],
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$sets = $this->teamScopedDockers($teamId);
|
||||
|
||||
return response()->json(
|
||||
$sets['standalone']->concat($sets['swarm'])
|
||||
->map(fn ($destination) => $this->transform($destination))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List destinations by server',
|
||||
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
|
||||
path: '/servers/{server_uuid}/destinations',
|
||||
operationId: 'list-server-destinations',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destinations attached to the server.',
|
||||
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
|
||||
->whereTeamId($teamId)
|
||||
->whereUuid($server_uuid)
|
||||
->firstOrFail();
|
||||
$list = $server->standaloneDockers->concat($server->swarmDockers);
|
||||
|
||||
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get destination',
|
||||
description: 'Get a Docker network destination by UUID.',
|
||||
path: '/destinations/{uuid}',
|
||||
operationId: 'get-destination-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destination details.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create destination',
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team.',
|
||||
path: '/servers/{server_uuid}/destinations',
|
||||
operationId: 'create-server-destination',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['network'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
||||
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Destination created.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
|
||||
|
||||
$allowed = ['name', 'network', 'type'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'nullable|string|max:255',
|
||||
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
|
||||
'type' => 'nullable|in:standalone,swarm',
|
||||
]);
|
||||
$extra = array_diff(array_keys($request->all()), $allowed);
|
||||
if ($validator->fails() || ! empty($extra)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extra)) {
|
||||
foreach ($extra as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
|
||||
$type = $request->input('type', $expectedType);
|
||||
if ($type !== $expectedType) {
|
||||
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
|
||||
}
|
||||
|
||||
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
|
||||
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
|
||||
|
||||
$this->authorize('create', $class);
|
||||
|
||||
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
|
||||
if ($exists) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$destination = $class::create([
|
||||
'name' => $name,
|
||||
'network' => $request->input('network'),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.destination.created', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destination->uuid,
|
||||
'destination_name' => $destination->name,
|
||||
'destination_type' => $type,
|
||||
'server_uuid' => $server->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
|
||||
|
||||
return in_array($sqlState, ['23000', '23505'], true)
|
||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update destination',
|
||||
description: 'Update a Docker network destination name. Network cannot be changed via the API.',
|
||||
path: '/destinations/{uuid}',
|
||||
operationId: 'update-destination-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destination updated.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowed = ['name'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
$extra = array_diff(array_keys($request->all()), $allowed);
|
||||
if ($validator->fails() || ! empty($extra)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extra)) {
|
||||
foreach ($extra as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
$this->authorize('update', $destination);
|
||||
|
||||
$destination->update(['name' => $request->input('name')]);
|
||||
$destination->load('server:id,uuid');
|
||||
|
||||
auditLog('api.destination.updated', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destination->uuid,
|
||||
'destination_name' => $destination->name,
|
||||
'destination_type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
||||
'server_uuid' => $destination->server?->uuid,
|
||||
'changed_fields' => ['name'],
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete destination',
|
||||
description: 'Delete an unused Docker network destination.',
|
||||
path: '/destinations/{uuid}',
|
||||
operationId: 'delete-destination-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destination deleted.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Destination has attached resources.'),
|
||||
],
|
||||
)]
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
$this->authorize('delete', $destination);
|
||||
|
||||
// Guard against deleting destinations with attached resources. attachedTo()
|
||||
// is recent on the destination models; fall back to a manual check for
|
||||
// older Coolify versions (e.g. 4.0.0-beta.470).
|
||||
if (method_exists($destination, 'attachedTo')) {
|
||||
if ($destination->attachedTo()) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
} else {
|
||||
$hasAttached = $destination->applications()->exists()
|
||||
|| $destination->postgresqls()->exists()
|
||||
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|
||||
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|
||||
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|
||||
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|
||||
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|
||||
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|
||||
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|
||||
|| (method_exists($destination, 'services') && $destination->services()->exists());
|
||||
if ($hasAttached) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
}
|
||||
if ($destination instanceof StandaloneDocker) {
|
||||
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
|
||||
}
|
||||
|
||||
$destinationUuid = $destination->uuid;
|
||||
$destinationName = $destination->name;
|
||||
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
|
||||
$serverUuid = $destination->server?->uuid;
|
||||
|
||||
$destination->delete();
|
||||
|
||||
auditLog('api.destination.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destinationUuid,
|
||||
'destination_name' => $destinationName,
|
||||
'destination_type' => $destinationType,
|
||||
'server_uuid' => $serverUuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DigitalOceanController extends Controller
|
||||
{
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
}
|
||||
|
||||
private function digitalOceanToken(Request $request, int $teamId): CloudProviderToken|JsonResponse
|
||||
{
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($this->getCloudProviderTokenUuid($request))
|
||||
->where('provider', 'digitalocean')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'DigitalOcean cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/digitalocean/regions',
|
||||
operationId: 'get-digitalocean-regions',
|
||||
summary: 'Get DigitalOcean regions',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of DigitalOcean regions.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
]
|
||||
)]
|
||||
public function regions(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = $this->digitalOceanToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new DigitalOceanService($token->token))->getRegions());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch DigitalOcean regions.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/digitalocean/sizes',
|
||||
operationId: 'get-digitalocean-sizes',
|
||||
summary: 'Get DigitalOcean sizes',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of DigitalOcean sizes.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
]
|
||||
)]
|
||||
public function sizes(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = $this->digitalOceanToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new DigitalOceanService($token->token))->getSizes());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch DigitalOcean sizes.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/digitalocean/images',
|
||||
operationId: 'get-digitalocean-images',
|
||||
summary: 'Get DigitalOcean images',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of DigitalOcean images.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
]
|
||||
)]
|
||||
public function images(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = $this->digitalOceanToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new DigitalOceanService($token->token))->getImages());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch DigitalOcean images.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/digitalocean/ssh-keys',
|
||||
operationId: 'get-digitalocean-ssh-keys',
|
||||
summary: 'Get DigitalOcean SSH keys',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of DigitalOcean SSH keys.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = $this->digitalOceanToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new DigitalOceanService($token->token))->getSshKeys());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch DigitalOcean SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/servers/digitalocean',
|
||||
operationId: 'create-digitalocean-server',
|
||||
summary: 'Create a server on DigitalOcean',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['DigitalOcean'],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request): JsonResponse
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'region',
|
||||
'size',
|
||||
'image',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_ipv6',
|
||||
'monitoring',
|
||||
'digitalocean_ssh_key_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'region' => 'required|string',
|
||||
'size' => 'required|string',
|
||||
'image' => 'required',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'monitoring' => 'nullable|boolean',
|
||||
'digitalocean_ssh_key_ids' => 'nullable|array',
|
||||
'digitalocean_ssh_key_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
$request->offsetSet('name', $request->name ?: generate_random_name());
|
||||
$request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true));
|
||||
$request->offsetSet('monitoring', $request->boolean('monitoring', true));
|
||||
$request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []);
|
||||
$request->offsetSet('instant_validate', $request->boolean('instant_validate', false));
|
||||
|
||||
$token = $this->digitalOceanToken($request, $teamId);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$digitalOceanService = null;
|
||||
$dropletId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$digitalOceanService = new DigitalOceanService($token->token);
|
||||
$sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey);
|
||||
|
||||
$sshKeys = array_values(array_unique(array_merge(
|
||||
[$sshKeyId],
|
||||
$request->digitalocean_ssh_key_ids
|
||||
)));
|
||||
|
||||
$normalizedServerName = strtolower(trim($request->name));
|
||||
$params = [
|
||||
'name' => $normalizedServerName,
|
||||
'region' => $request->region,
|
||||
'size' => $request->size,
|
||||
'image' => $request->image,
|
||||
'ssh_keys' => $sshKeys,
|
||||
'ipv6' => $request->enable_ipv6,
|
||||
'monitoring' => $request->monitoring,
|
||||
];
|
||||
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
}
|
||||
|
||||
$droplet = $digitalOceanService->createDroplet($params);
|
||||
$dropletId = (int) $droplet['id'];
|
||||
|
||||
$server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server {
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
|
||||
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
|
||||
|
||||
if ($ipAddress) {
|
||||
$server->update([
|
||||
'ip' => $ipAddress,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.digitalocean_droplet.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'ip' => $server->ip,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'ip' => $server->ip,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
logger()->error('Failed to create DigitalOcean server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
|
||||
{
|
||||
if (! $digitalOceanService || ! $dropletId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$digitalOceanService->deleteDroplet($dropletId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int
|
||||
{
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
foreach ($digitalOceanService->getSshKeys() as $key) {
|
||||
if (($key['fingerprint'] ?? null) === $md5Fingerprint) {
|
||||
return (int) $key['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey());
|
||||
|
||||
return (int) $uploadedKey['id'];
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,17 @@ class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
{
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$githubApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
} else {
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($githubApp);
|
||||
}
|
||||
@@ -129,7 +136,7 @@ class GithubController extends Controller
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
||||
],
|
||||
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -183,6 +190,7 @@ class GithubController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GithubApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -204,10 +212,14 @@ class GithubController extends Controller
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$request->merge([
|
||||
'organization' => normalizeGithubOrganization($request->input('organization')),
|
||||
]);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
|
||||
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
@@ -251,7 +263,9 @@ class GithubController extends Controller
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'api_url' => filled($request->input('api_url'))
|
||||
? $request->input('api_url')
|
||||
: githubApiUrlFromHtmlUrl($request->input('html_url')),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
@@ -271,6 +285,12 @@ class GithubController extends Controller
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
auditLog('api.github_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
@@ -558,6 +578,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('update', $githubApp);
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
@@ -581,13 +602,17 @@ class GithubController extends Controller
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
@@ -631,6 +656,13 @@ class GithubController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
|
||||
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
@@ -650,6 +682,13 @@ class GithubController extends Controller
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
auditLog('api.github_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
@@ -724,6 +763,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $githubApp);
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
@@ -734,8 +774,16 @@ class GithubController extends Controller
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $githubApp->uuid;
|
||||
$deletedName = $githubApp->name;
|
||||
$githubApp->delete();
|
||||
|
||||
auditLog('api.github_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $deletedUuid,
|
||||
'github_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class GitlabController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(GitlabApp $gitlabApp)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$gitlabApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
]);
|
||||
} else {
|
||||
$gitlabApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($gitlabApp);
|
||||
}
|
||||
|
||||
private function findTeamGitlabApp(int|string $gitlabAppId, int $teamId): GitlabApp
|
||||
{
|
||||
return GitlabApp::where('id', $gitlabAppId)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string
|
||||
{
|
||||
return rtrim($htmlUrl, '/').'/api/v4';
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all GitLab apps for the current team (and system-wide sources).',
|
||||
path: '/gitlab-apps',
|
||||
operationId: 'list-gitlab-apps',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of GitLab apps.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'is_public' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_gitlab_apps(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$gitlabApps = GitlabApp::where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$gitlabApps = $gitlabApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
});
|
||||
|
||||
return response()->json($gitlabApps);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create GitLab App',
|
||||
description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.',
|
||||
path: '/gitlab-apps',
|
||||
operationId: 'create-gitlab-app',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'GitLab app creation payload.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the GitLab app.'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitLab instance URL (e.g., https://gitlab.com).'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL (defaults to {html_url}/api/v4).'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional comma-separated group names to filter repositories.'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application ID.'],
|
||||
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application Secret.'],
|
||||
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token (auto-generated when omitted).'],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI registered in GitLab.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (non-cloud instances only).'],
|
||||
],
|
||||
required: ['name', 'html_url'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'GitLab app created successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_gitlab_app(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GitlabApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'html_url',
|
||||
'api_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'group_name',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'redirect_uri',
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
'group_name' => 'nullable|string|max:255',
|
||||
'client_id' => 'nullable|string|max:255',
|
||||
'client_secret' => 'nullable|string',
|
||||
'webhook_token' => 'nullable|string',
|
||||
// Callback to this Coolify instance — may be a private/LAN URL; do not use SafeExternalUrl.
|
||||
'redirect_uri' => ['nullable', 'string', 'url'],
|
||||
'is_system_wide' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$htmlUrl = rtrim((string) $request->input('html_url'), '/');
|
||||
$apiUrl = filled($request->input('api_url'))
|
||||
? rtrim((string) $request->input('api_url'), '/')
|
||||
: $this->gitlabApiUrlFromHtmlUrl($htmlUrl);
|
||||
|
||||
$payload = [
|
||||
'name' => $request->input('name'),
|
||||
'html_url' => $htmlUrl,
|
||||
'api_url' => $apiUrl,
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
'group_name' => $request->input('group_name'),
|
||||
'client_id' => $request->input('client_id'),
|
||||
'client_secret' => $request->input('client_secret'),
|
||||
'webhook_token' => $request->input('webhook_token') ?: Str::random(32),
|
||||
'redirect_uri' => $request->input('redirect_uri'),
|
||||
'is_public' => false,
|
||||
'team_id' => $teamId,
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$payload['is_system_wide'] = $request->boolean('is_system_wide', false);
|
||||
}
|
||||
|
||||
$gitlabApp = GitlabApp::create($payload);
|
||||
|
||||
auditLog('api.gitlab_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $gitlabApp->uuid,
|
||||
'gitlab_app_name' => $gitlabApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/gitlab-apps/{gitlab_app_id}',
|
||||
operationId: 'updateGitlabApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
summary: 'Update GitLab App',
|
||||
description: 'Update an existing GitLab app.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'gitlab_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitLab App ID'
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'GitLab App name'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitLab HTML URL'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional group filter'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application ID'],
|
||||
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application Secret'],
|
||||
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token'],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitLab app updated successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitLab app updated successfully'],
|
||||
'data' => ['type' => 'object', 'description' => 'Updated GitLab app data'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitLab app not found'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_gitlab_app(Request $request, $gitlab_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
|
||||
$this->authorize('update', $gitlabApp);
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'html_url',
|
||||
'api_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'group_name',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'redirect_uri',
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$allowedFields[] = 'is_system_wide';
|
||||
}
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string|max:255';
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$rules['html_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['custom_user'])) {
|
||||
$rules['custom_user'] = 'string|max:255';
|
||||
}
|
||||
if (isset($payload['custom_port'])) {
|
||||
$rules['custom_port'] = 'integer|min:1|max:65535';
|
||||
}
|
||||
if (array_key_exists('group_name', $payload)) {
|
||||
$rules['group_name'] = 'nullable|string|max:255';
|
||||
}
|
||||
if (array_key_exists('client_id', $payload)) {
|
||||
$rules['client_id'] = 'nullable|string|max:255';
|
||||
}
|
||||
if (array_key_exists('client_secret', $payload)) {
|
||||
$rules['client_secret'] = 'nullable|string';
|
||||
}
|
||||
if (array_key_exists('webhook_token', $payload)) {
|
||||
$rules['webhook_token'] = 'nullable|string';
|
||||
}
|
||||
if (array_key_exists('redirect_uri', $payload)) {
|
||||
// Callback to this Coolify instance — may be a private/LAN URL.
|
||||
$rules['redirect_uri'] = 'nullable|url';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
}
|
||||
|
||||
$validator = customApiValidator($payload, $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation error',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (isset($payload['html_url'])) {
|
||||
$payload['html_url'] = rtrim((string) $payload['html_url'], '/');
|
||||
if (! filled($payload['api_url'] ?? null)) {
|
||||
$payload['api_url'] = $this->gitlabApiUrlFromHtmlUrl($payload['html_url']);
|
||||
}
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$payload['api_url'] = rtrim((string) $payload['api_url'], '/');
|
||||
}
|
||||
|
||||
$gitlabApp->update($payload);
|
||||
|
||||
auditLog('api.gitlab_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $gitlabApp->uuid,
|
||||
'gitlab_app_name' => $gitlabApp->name,
|
||||
'changed_fields' => array_values(array_diff(array_keys($payload), ['client_secret', 'webhook_token'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitLab app updated successfully',
|
||||
'data' => $this->removeSensitiveData($gitlabApp->fresh()),
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitLab app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/gitlab-apps/{gitlab_app_id}',
|
||||
operationId: 'deleteGitlabApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
summary: 'Delete GitLab App',
|
||||
description: 'Delete a GitLab app if it is not being used by any applications.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'gitlab_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitLab App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitLab app deleted successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitLab app deleted successfully'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitLab app not found'),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Conflict - GitLab app is in use',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'This GitLab app is being used by 5 application(s). Please delete all applications first.'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_gitlab_app($gitlab_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
|
||||
$this->authorize('delete', $gitlabApp);
|
||||
|
||||
if ($gitlabApp->applications->isNotEmpty()) {
|
||||
$count = $gitlabApp->applications->count();
|
||||
|
||||
return response()->json([
|
||||
'message' => "This GitLab app is being used by {$count} application(s). Please delete all applications first.",
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $gitlabApp->uuid;
|
||||
$deletedName = $gitlabApp->name;
|
||||
$gitlabApp->delete();
|
||||
|
||||
auditLog('api.gitlab_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $deletedUuid,
|
||||
'gitlab_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitLab app deleted successfully',
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitLab app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -12,6 +13,7 @@ use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -114,6 +116,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -121,7 +124,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +238,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -242,7 +246,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -354,7 +359,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +448,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -450,7 +456,196 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Firewalls',
|
||||
description: 'Get all existing Hetzner firewalls for the current project.',
|
||||
path: '/hetzner/firewalls',
|
||||
operationId: 'get-hetzner-firewalls',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner firewalls.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Networks',
|
||||
description: 'Get all existing Hetzner private networks for the current project.',
|
||||
path: '/hetzner/networks',
|
||||
operationId: 'get-hetzner-networks',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner networks.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'ip_range' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +676,10 @@ class HetznerController extends Controller
|
||||
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
@@ -539,7 +737,10 @@ class HetznerController extends Controller
|
||||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'enable_backups',
|
||||
'hetzner_ssh_key_ids',
|
||||
'hetzner_firewall_ids',
|
||||
'hetzner_network_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
@@ -548,9 +749,10 @@ class HetznerController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -564,8 +766,13 @@ class HetznerController extends Controller
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'enable_backups' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'hetzner_firewall_ids' => 'nullable|array',
|
||||
'hetzner_firewall_ids.*' => 'integer',
|
||||
'hetzner_network_ids' => 'nullable|array',
|
||||
'hetzner_network_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
@@ -601,13 +808,32 @@ class HetznerController extends Controller
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->enable_backups)) {
|
||||
$request->offsetSet('enable_backups', false);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_firewall_ids)) {
|
||||
$request->offsetSet('hetzner_firewall_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_network_ids)) {
|
||||
$request->offsetSet('hetzner_network_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'enable_ipv4' => ['Enable at least one public IP protocol.'],
|
||||
'enable_ipv6' => ['Enable at least one public IP protocol.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
@@ -618,6 +844,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
// Validate private key
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
@@ -679,6 +906,18 @@ class HetznerController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($request->hetzner_network_ids));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
@@ -715,11 +954,27 @@ class HetznerController extends Controller
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.hetzner_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
@@ -733,7 +988,7 @@ class HetznerController extends Controller
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class NotificationsController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return array{model: class-string<Model>, rules: array<string, mixed>}
|
||||
*/
|
||||
private function channelConfig(string $channel): array
|
||||
{
|
||||
return match ($channel) {
|
||||
'email' => [
|
||||
'model' => EmailNotificationSettings::class,
|
||||
'rules' => [
|
||||
'smtp_enabled' => 'sometimes|boolean',
|
||||
'smtp_from_address' => 'sometimes|nullable|email',
|
||||
'smtp_from_name' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_recipients' => 'sometimes|nullable|string|max:1000',
|
||||
'smtp_host' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
|
||||
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
|
||||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_password' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
'use_instance_email_settings' => 'sometimes|boolean',
|
||||
'deployment_success_email_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_email_notifications' => 'sometimes|boolean',
|
||||
'status_change_email_notifications' => 'sometimes|boolean',
|
||||
'backup_success_email_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_email_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_email_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_email_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_email_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_email_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_email_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_email_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_email_notifications' => 'sometimes|boolean',
|
||||
'server_patch_email_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_email_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'discord' => [
|
||||
'model' => DiscordNotificationSettings::class,
|
||||
'rules' => [
|
||||
'discord_enabled' => 'sometimes|boolean',
|
||||
'discord_webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl],
|
||||
'deployment_success_discord_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'status_change_discord_notifications' => 'sometimes|boolean',
|
||||
'backup_success_discord_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_discord_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_discord_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_discord_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_discord_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_discord_notifications' => 'sometimes|boolean',
|
||||
'server_patch_discord_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_discord_notifications' => 'sometimes|boolean',
|
||||
'discord_ping_enabled' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'slack' => [
|
||||
'model' => SlackNotificationSettings::class,
|
||||
'rules' => [
|
||||
'slack_enabled' => 'sometimes|boolean',
|
||||
'slack_webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl],
|
||||
'deployment_success_slack_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'status_change_slack_notifications' => 'sometimes|boolean',
|
||||
'backup_success_slack_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_slack_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_slack_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_slack_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_slack_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_slack_notifications' => 'sometimes|boolean',
|
||||
'server_patch_slack_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_slack_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'telegram' => [
|
||||
'model' => TelegramNotificationSettings::class,
|
||||
'rules' => [
|
||||
'telegram_enabled' => 'sometimes|boolean',
|
||||
'telegram_token' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_chat_id' => 'sometimes|nullable|string|max:255',
|
||||
'deployment_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'status_change_telegram_notifications' => 'sometimes|boolean',
|
||||
'backup_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_telegram_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_telegram_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_telegram_notifications' => 'sometimes|boolean',
|
||||
'server_patch_telegram_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_telegram_notifications' => 'sometimes|boolean',
|
||||
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_scheduled_task_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_docker_cleanup_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_docker_cleanup_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_server_disk_usage_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_server_reachable_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_server_unreachable_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_server_patch_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_traefik_outdated_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
],
|
||||
],
|
||||
'pushover' => [
|
||||
'model' => PushoverNotificationSettings::class,
|
||||
'rules' => [
|
||||
'pushover_enabled' => 'sometimes|boolean',
|
||||
'pushover_user_key' => 'sometimes|nullable|string|max:255',
|
||||
'pushover_api_token' => 'sometimes|nullable|string|max:255',
|
||||
'deployment_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'status_change_pushover_notifications' => 'sometimes|boolean',
|
||||
'backup_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_pushover_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_pushover_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_pushover_notifications' => 'sometimes|boolean',
|
||||
'server_patch_pushover_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_pushover_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'webhook' => [
|
||||
'model' => WebhookNotificationSettings::class,
|
||||
'rules' => [
|
||||
'webhook_enabled' => 'sometimes|boolean',
|
||||
'webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl],
|
||||
'deployment_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'status_change_webhook_notifications' => 'sometimes|boolean',
|
||||
'backup_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'docker_cleanup_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'server_disk_usage_webhook_notifications' => 'sometimes|boolean',
|
||||
'server_reachable_webhook_notifications' => 'sometimes|boolean',
|
||||
'server_unreachable_webhook_notifications' => 'sometimes|boolean',
|
||||
'server_patch_webhook_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_webhook_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
default => throw new \InvalidArgumentException("Unknown notification channel [{$channel}]."),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function allowedFields(string $channel): array
|
||||
{
|
||||
$config = $this->channelConfig($channel);
|
||||
/** @var Model $model */
|
||||
$model = new $config['model'];
|
||||
|
||||
return array_values(array_filter(
|
||||
$model->getFillable(),
|
||||
fn (string $field): bool => $field !== 'team_id'
|
||||
));
|
||||
}
|
||||
|
||||
private function serializeSettings(Model $settings): array
|
||||
{
|
||||
exposeSensitiveFields($settings);
|
||||
|
||||
$settings->makeHidden(['team']);
|
||||
|
||||
return serializeApiResponse($settings)->toArray();
|
||||
}
|
||||
|
||||
private function resolveSettings(string $channel, int $teamId): Model
|
||||
{
|
||||
$config = $this->channelConfig($channel);
|
||||
$modelClass = $config['model'];
|
||||
|
||||
/** @var Model $settings */
|
||||
$settings = $modelClass::query()->firstOrCreate(['team_id' => $teamId]);
|
||||
$settings->setRelation('team', Team::query()->findOrFail($teamId));
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private function showChannel(string $channel): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$settings = $this->resolveSettings($channel, $teamId);
|
||||
$this->authorize('view', $settings);
|
||||
|
||||
return response()->json($this->serializeSettings($settings));
|
||||
}
|
||||
|
||||
private function updateChannel(Request $request, string $channel): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = $this->allowedFields($channel);
|
||||
$body = $request->json()->all();
|
||||
$config = $this->channelConfig($channel);
|
||||
|
||||
$validator = customApiValidator($body, $config['rules']);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = $this->resolveSettings($channel, $teamId);
|
||||
$this->authorize('update', $settings);
|
||||
|
||||
$settings->fill(array_intersect_key($body, array_flip($allowedFields)));
|
||||
$settings->save();
|
||||
|
||||
auditLog("api.notifications.{$channel}.updated", [
|
||||
'team_id' => $teamId,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
$settings->refresh();
|
||||
$settings->setRelation('team', Team::query()->findOrFail($teamId));
|
||||
|
||||
return response()->json($this->serializeSettings($settings));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get email notification settings',
|
||||
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'get-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Email notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function email(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('email');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update email notification settings',
|
||||
description: 'Update the current team email notification settings.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'update-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated email notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_email(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'email');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Discord notification settings',
|
||||
description: 'Get the current team Discord notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/discord',
|
||||
operationId: 'get-current-team-discord-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Discord notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function discord(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('discord');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Discord notification settings',
|
||||
description: 'Update the current team Discord notification settings.',
|
||||
path: '/notifications/discord',
|
||||
operationId: 'update-current-team-discord-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Discord notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_discord(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'discord');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Slack notification settings',
|
||||
description: 'Get the current team Slack notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/slack',
|
||||
operationId: 'get-current-team-slack-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Slack notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function slack(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('slack');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Slack notification settings',
|
||||
description: 'Update the current team Slack notification settings.',
|
||||
path: '/notifications/slack',
|
||||
operationId: 'update-current-team-slack-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Slack notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_slack(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'slack');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Telegram notification settings',
|
||||
description: 'Get the current team Telegram notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/telegram',
|
||||
operationId: 'get-current-team-telegram-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Telegram notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function telegram(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('telegram');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Telegram notification settings',
|
||||
description: 'Update the current team Telegram notification settings.',
|
||||
path: '/notifications/telegram',
|
||||
operationId: 'update-current-team-telegram-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Telegram notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_telegram(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'telegram');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Pushover notification settings',
|
||||
description: 'Get the current team Pushover notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/pushover',
|
||||
operationId: 'get-current-team-pushover-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Pushover notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function pushover(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('pushover');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Pushover notification settings',
|
||||
description: 'Update the current team Pushover notification settings.',
|
||||
path: '/notifications/pushover',
|
||||
operationId: 'update-current-team-pushover-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Pushover notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_pushover(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'pushover');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get webhook notification settings',
|
||||
description: 'Get the current team webhook notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/webhook',
|
||||
operationId: 'get-current-team-webhook-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Webhook notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function webhook(Request $request): JsonResponse
|
||||
{
|
||||
return $this->showChannel('webhook');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update webhook notification settings',
|
||||
description: 'Update the current team webhook notification settings.',
|
||||
path: '/notifications/webhook',
|
||||
operationId: 'update-current-team-webhook-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Notifications'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated webhook notification settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_webhook(Request $request): JsonResponse
|
||||
{
|
||||
return $this->updateChannel($request, 'webhook');
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,20 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class OtherController extends Controller
|
||||
{
|
||||
public function post_required(): JsonResponse
|
||||
{
|
||||
return response()
|
||||
->json(['message' => 'This endpoint has changed to a POST request.'], 405)
|
||||
->header('Allow', 'POST');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Version',
|
||||
description: 'Get Coolify version.',
|
||||
@@ -41,7 +49,7 @@ class OtherController extends Controller
|
||||
return response(config('constants.coolify.version'));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Enable API',
|
||||
description: 'Enable API (only with root permissions).',
|
||||
path: '/enable',
|
||||
@@ -85,15 +93,19 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => true]);
|
||||
|
||||
auditLog('api.instance.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Disable API',
|
||||
description: 'Disable API (only with root permissions).',
|
||||
path: '/disable',
|
||||
@@ -137,21 +149,141 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => false]);
|
||||
|
||||
auditLog('api.instance.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$content = $request->input('content');
|
||||
$data = $request->validate([
|
||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
||||
if ($webhook_url) {
|
||||
Http::post($webhook_url, [
|
||||
'content' => $content,
|
||||
Http::timeout(5)->post($webhook_url, [
|
||||
'content' => $data['content'],
|
||||
'allowed_mentions' => ['parse' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -184,6 +316,6 @@ class OtherController extends Controller
|
||||
)]
|
||||
public function healthcheck(Request $request)
|
||||
{
|
||||
return 'OK';
|
||||
return response('OK');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$project->load(['environments']);
|
||||
|
||||
@@ -165,6 +166,9 @@ class ProjectController extends Controller
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
|
||||
collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services'])
|
||||
->flatMap(fn (string $relation) => $environment->{$relation})
|
||||
->each(fn ($resource) => exposeSensitiveFields($resource));
|
||||
|
||||
return response()->json(serializeApiResponse($environment));
|
||||
}
|
||||
@@ -233,6 +237,7 @@ class ProjectController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Project::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
@@ -258,12 +263,18 @@ class ProjectController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = Project::forceCreate([
|
||||
$project = Project::create([
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
auditLog('api.project.created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -379,9 +390,17 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$project->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.project.updated', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
@@ -456,12 +475,21 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $project);
|
||||
if (! $project->isEmpty()) {
|
||||
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$projectUuid = $project->uuid;
|
||||
$projectName = $project->name;
|
||||
$project->delete();
|
||||
|
||||
auditLog('api.project.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $projectUuid,
|
||||
'project_name' => $projectName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Project deleted.']);
|
||||
}
|
||||
|
||||
@@ -631,6 +659,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
|
||||
if ($existingEnvironment) {
|
||||
@@ -641,11 +670,167 @@ class ProjectController extends Controller
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
auditLog('api.project.environment_created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'environment_name' => $environment->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $environment->uuid,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Environment',
|
||||
description: 'Update environment by name or UUID within a project.',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}',
|
||||
operationId: 'update-environment',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Projects'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Environment fields to update.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the environment.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The description of the environment.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Environment updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'env123'],
|
||||
'name' => ['type' => 'string', 'example' => 'staging'],
|
||||
'description' => ['type' => 'string', 'example' => 'Staging environment'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Project or environment not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Environment with this name already exists.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_environment(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(required: false),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
], ValidationPatterns::combinedMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||
}
|
||||
if (! $request->environment_name_or_uuid) {
|
||||
return response()->json(['message' => 'Environment name or UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
|
||||
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
|
||||
if (! $environment) {
|
||||
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
|
||||
}
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
if ($request->filled('name') && $request->name !== $environment->name) {
|
||||
$existingEnvironment = $project->environments()
|
||||
->where('name', $request->name)
|
||||
->where('id', '!=', $environment->id)
|
||||
->first();
|
||||
if ($existingEnvironment) {
|
||||
return response()->json(['message' => 'Environment with this name already exists.'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
$environment->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.project.environment_updated', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'environment_name' => $environment->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $environment->uuid,
|
||||
'name' => $environment->name,
|
||||
'description' => $environment->description,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Environment',
|
||||
description: 'Delete environment by name or UUID. Environment must be empty.',
|
||||
@@ -718,13 +903,23 @@ class ProjectController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $environment);
|
||||
|
||||
if (! $environment->isEmpty()) {
|
||||
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$envUuid = $environment->uuid;
|
||||
$envName = $environment->name;
|
||||
$environment->delete();
|
||||
|
||||
auditLog('api.project.environment_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $envUuid,
|
||||
'environment_name' => $envName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment deleted.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ class ResourcesController extends Controller
|
||||
}
|
||||
$resources = $resources->flatten();
|
||||
$resources = $resources->map(function ($resource) {
|
||||
exposeSensitiveFields($resource);
|
||||
$payload = $resource->toArray();
|
||||
$payload['status'] = $resource->status;
|
||||
$payload['type'] = $resource->type();
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class S3StoragesController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(S3Storage $storage)
|
||||
{
|
||||
$storage->makeHidden([
|
||||
'id',
|
||||
]);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible([
|
||||
'key',
|
||||
'secret',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, error: string|null}
|
||||
*/
|
||||
private function validateStorageConnection(S3Storage $storage): array
|
||||
{
|
||||
try {
|
||||
$storage->testConnection(shouldSave: true);
|
||||
|
||||
return ['valid' => true, 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
return ['valid' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
* @param array<int, string> $allowedFields
|
||||
* @param array<string, mixed> $rules
|
||||
*/
|
||||
private function validateBody(array $body, array $allowedFields, array $rules): ?JsonResponse
|
||||
{
|
||||
$validator = customApiValidator($body, $rules);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List S3 Storages',
|
||||
description: 'List all S3 storages for the authenticated team.',
|
||||
path: '/s3-storages',
|
||||
operationId: 'list-s3-storages',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all S3 storages.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string', 'nullable' => true],
|
||||
'endpoint' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string'],
|
||||
'region' => ['type' => 'string'],
|
||||
'is_usable' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$storages = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->get()
|
||||
->map(function ($storage) {
|
||||
return $this->removeSensitiveData($storage);
|
||||
});
|
||||
|
||||
return response()->json($storages);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get S3 Storage',
|
||||
description: 'Get S3 storage by UUID.',
|
||||
path: '/s3-storages/{uuid}',
|
||||
operationId: 'get-s3-storage-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get S3 storage by UUID',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string', 'nullable' => true],
|
||||
'endpoint' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string'],
|
||||
'region' => ['type' => 'string'],
|
||||
'is_usable' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->whereUuid($request->uuid)
|
||||
->first();
|
||||
|
||||
if (is_null($storage)) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $storage);
|
||||
|
||||
return response()->json($this->removeSensitiveData($storage));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create S3 Storage',
|
||||
description: 'Create a new S3 storage configuration for the authenticated team.',
|
||||
path: '/s3-storages',
|
||||
operationId: 'create-s3-storage',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'S3 storage details',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'endpoint', 'bucket', 'region', 'key', 'secret'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'example' => 'My S3 Storage', 'description' => 'A friendly name for the storage.'],
|
||||
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
|
||||
'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'],
|
||||
'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'],
|
||||
'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'],
|
||||
'key' => ['type' => 'string', 'description' => 'Access key.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
|
||||
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'S3 storage created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the S3 storage.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [S3Storage::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validationError = $this->validateBody($body, $allowedFields, [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'endpoint' => ['required', 'string', 'max:255', new SafeWebhookUrl],
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
'region' => 'required|string|max:255',
|
||||
'key' => 'required|string|max:255',
|
||||
'secret' => 'required|string|max:255',
|
||||
'is_usable' => 'sometimes|boolean',
|
||||
]);
|
||||
if ($validationError instanceof JsonResponse) {
|
||||
return $validationError;
|
||||
}
|
||||
|
||||
$storage = S3Storage::create([
|
||||
'team_id' => $teamId,
|
||||
'name' => $body['name'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'endpoint' => $body['endpoint'],
|
||||
'bucket' => $body['bucket'],
|
||||
'region' => $body['region'],
|
||||
'key' => $body['key'],
|
||||
'secret' => $body['secret'],
|
||||
'is_usable' => $body['is_usable'] ?? false,
|
||||
]);
|
||||
|
||||
auditLog('api.s3_storage.created', [
|
||||
'team_id' => $teamId,
|
||||
's3_storage_uuid' => $storage->uuid,
|
||||
's3_storage_name' => $storage->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $storage->uuid,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update S3 Storage',
|
||||
description: 'Update S3 storage by UUID.',
|
||||
path: '/s3-storages/{uuid}',
|
||||
operationId: 'update-s3-storage-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'S3 storage fields to update.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'A friendly name for the storage.'],
|
||||
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
|
||||
'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'],
|
||||
'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'],
|
||||
'region' => ['type' => 'string', 'description' => 'S3 region.'],
|
||||
'key' => ['type' => 'string', 'description' => 'Access key.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
|
||||
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'S3 storage updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validationError = $this->validateBody($body, $allowedFields, [
|
||||
'name' => ValidationPatterns::nameRules(required: false),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'endpoint' => ['sometimes', 'string', 'max:255', new SafeWebhookUrl],
|
||||
'bucket' => ['sometimes', new ValidS3BucketName],
|
||||
'region' => 'sometimes|string|max:255',
|
||||
'key' => 'sometimes|string|max:255',
|
||||
'secret' => 'sometimes|string|max:255',
|
||||
'is_usable' => 'sometimes|boolean',
|
||||
]);
|
||||
if ($validationError instanceof JsonResponse) {
|
||||
return $validationError;
|
||||
}
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $storage);
|
||||
|
||||
$storage->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
auditLog('api.s3_storage.updated', [
|
||||
'team_id' => $teamId,
|
||||
's3_storage_uuid' => $storage->uuid,
|
||||
's3_storage_name' => $storage->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $storage->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete S3 Storage',
|
||||
description: 'Delete S3 storage by UUID.',
|
||||
path: '/s3-storages/{uuid}',
|
||||
operationId: 'delete-s3-storage-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the S3 storage.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'S3 storage deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'S3 storage deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $storage);
|
||||
|
||||
$storageUuid = $storage->uuid;
|
||||
$storageName = $storage->name;
|
||||
$storage->delete();
|
||||
|
||||
auditLog('api.s3_storage.deleted', [
|
||||
'team_id' => $teamId,
|
||||
's3_storage_uuid' => $storageUuid,
|
||||
's3_storage_name' => $storageName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'S3 storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Validate S3 Storage',
|
||||
description: 'Validate an S3 storage connection using ListObjectsV2.',
|
||||
path: '/s3-storages/{uuid}/validate',
|
||||
operationId: 'validate-s3-storage-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['S3 Storages'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'S3 storage validation result.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'valid' => ['type' => 'boolean', 'example' => true],
|
||||
'message' => ['type' => 'string', 'example' => 'S3 storage connection is valid.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function validateStorage(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
}
|
||||
$this->authorize('validateConnection', $storage);
|
||||
|
||||
$validation = $this->validateStorageConnection($storage);
|
||||
|
||||
auditLog('api.s3_storage.validated', [
|
||||
'team_id' => $teamId,
|
||||
's3_storage_uuid' => $storage->uuid,
|
||||
's3_storage_name' => $storage->name,
|
||||
'valid' => $validation['valid'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'S3 storage connection is valid.' : $validation['error'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -33,7 +35,7 @@ class ScheduledTasksController extends Controller
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function listTasks(Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -44,12 +46,12 @@ class ScheduledTasksController extends Controller
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function createTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -105,15 +107,23 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->save();
|
||||
|
||||
auditLog('api.scheduled_task.created', [
|
||||
'team_id' => $teamId,
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 201);
|
||||
}
|
||||
|
||||
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function updateTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -161,22 +171,43 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.scheduled_task.updated', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 200);
|
||||
}
|
||||
|
||||
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
|
||||
if (! $deleted) {
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$taskUuid = $task->uuid;
|
||||
$taskName = $task->name;
|
||||
$task->delete();
|
||||
|
||||
auditLog('api.scheduled_task.deleted', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $taskUuid,
|
||||
'task_name' => $taskName,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Scheduled task deleted.']);
|
||||
}
|
||||
|
||||
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -194,6 +225,28 @@ class ScheduledTasksController extends Controller
|
||||
return response()->json($executions);
|
||||
}
|
||||
|
||||
private function executeTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
|
||||
auditLog('api.scheduled_task.executed', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Scheduled task execution queued.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tasks',
|
||||
description: 'List all scheduled tasks for an application.',
|
||||
@@ -238,7 +291,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -317,7 +370,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -404,7 +457,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -474,7 +527,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -542,7 +595,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -601,7 +654,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -680,7 +733,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -767,7 +820,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -837,7 +890,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -905,7 +958,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -919,4 +972,68 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
return $this->getExecutions($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Execute Task',
|
||||
description: 'Queue immediate execution of a scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/execute',
|
||||
operationId: 'execute-scheduled-task-by-application-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'task_uuid', in: 'path', required: true, description: 'UUID of the scheduled task.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Scheduled task execution queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function execute_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->executeTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Execute Task',
|
||||
description: 'Queue immediate execution of a scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/execute',
|
||||
operationId: 'execute-scheduled-task-by-service-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'task_uuid', in: 'path', required: true, description: 'UUID of the scheduled task.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Scheduled task execution queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function execute_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->executeTask($request, $service);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ class SecurityController extends Controller
|
||||
$team->makeHidden([
|
||||
'private_key',
|
||||
]);
|
||||
} else {
|
||||
$team->makeVisible([
|
||||
'private_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
@@ -110,6 +114,7 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('view', $key);
|
||||
|
||||
return response()->json($this->removeSensitiveData($key));
|
||||
}
|
||||
@@ -176,6 +181,7 @@ class SecurityController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [PrivateKey::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -232,6 +238,13 @@ class SecurityController extends Controller
|
||||
'private_key' => $request->private_key,
|
||||
]);
|
||||
|
||||
auditLog('api.private_key.created', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $key->uuid,
|
||||
'private_key_name' => $key->name,
|
||||
'fingerprint' => $fingerPrint,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $key->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -331,8 +344,16 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.private_key.updated', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $foundKey->uuid,
|
||||
'private_key_name' => $foundKey->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $foundKey->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -407,6 +428,7 @@ class SecurityController extends Controller
|
||||
if (is_null($key)) {
|
||||
return response()->json(['message' => 'Private Key not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $key);
|
||||
|
||||
if ($key->isInUse()) {
|
||||
return response()->json([
|
||||
@@ -415,8 +437,16 @@ class SecurityController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$keyUuid = $key->uuid;
|
||||
$keyName = $key->name;
|
||||
$key->forceDelete();
|
||||
|
||||
auditLog('api.private_key.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $keyUuid,
|
||||
'private_key_name' => $keyName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Private Key deleted.',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SentinelController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle a Sentinel agent metrics push.
|
||||
*
|
||||
* Sentinel pushes its full container list on a fixed interval (default 60s),
|
||||
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
|
||||
* server per minute, the job is only dispatched when the container state hash
|
||||
* changes, or when the force window has elapsed.
|
||||
*/
|
||||
public function push(Request $request)
|
||||
{
|
||||
$token = $request->header('Authorization');
|
||||
if (! $token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
if (! $server_uuid) {
|
||||
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server = Server::where('uuid', $server_uuid)->first();
|
||||
if (! $server) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_found', [
|
||||
'server_uuid' => $server_uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_functional', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server is not functional'], 401);
|
||||
}
|
||||
|
||||
if ($server->settings->sentinel_token !== $naked_token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'containers' => ['present', 'array'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(serializeApiResponse([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'ok'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether PushServerUpdateJob should be dispatched for this push.
|
||||
*
|
||||
* Dispatches when: first push (no cached hash), the container state changed,
|
||||
* or the force window elapsed.
|
||||
*/
|
||||
private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||
{
|
||||
$hash = $this->containerStateHash($data);
|
||||
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||
$forceKey = "sentinel:push-force:{$server->id}";
|
||||
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||
|
||||
try {
|
||||
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
|
||||
$cachedHash = Cache::get($hashKey);
|
||||
$forceActive = Cache::has($forceKey);
|
||||
|
||||
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
|
||||
|
||||
if ($shouldDispatch) {
|
||||
// Day-long TTL bounds memory if a server stops pushing entirely.
|
||||
Cache::put($hashKey, $hash, now()->addDay());
|
||||
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
|
||||
}
|
||||
|
||||
return $shouldDispatch;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||
* The snapshot completeness flag is included so a complete snapshot always
|
||||
* dispatches after a partial snapshot. Sorted by name so container ordering
|
||||
* from Sentinel does not affect the hash.
|
||||
*/
|
||||
private function containerStateHash(array $data): string
|
||||
{
|
||||
$containers = collect(data_get($data, 'containers', []))
|
||||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return hash('xxh128', json_encode([
|
||||
'snapshot_complete' => $this->isCompleteSnapshot($data),
|
||||
'containers' => $containers,
|
||||
]));
|
||||
}
|
||||
|
||||
private function isCompleteSnapshot(array $data): bool
|
||||
{
|
||||
return data_get($data, 'snapshot.complete', true) !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerCloudflareTunnelController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_cloudflare_tunnel',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
private function transform(Server $server): array
|
||||
{
|
||||
return [
|
||||
'is_cloudflare_tunnel' => (bool) $server->settings->is_cloudflare_tunnel,
|
||||
'ip' => $server->ip,
|
||||
'ip_previous' => $server->ip_previous,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Cloudflare Tunnel settings',
|
||||
description: 'Get Cloudflare Tunnel settings for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/cloudflare-tunnel',
|
||||
operationId: 'get-server-cloudflare-tunnel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Cloudflare Tunnel settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_cloudflare_tunnel', type: 'boolean'),
|
||||
new OA\Property(property: 'ip', type: 'string'),
|
||||
new OA\Property(property: 'ip_previous', type: 'string', nullable: true),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Cloudflare Tunnel settings',
|
||||
description: 'Update stored Cloudflare Tunnel settings for a server. Does not run remote cloudflared configuration; use enable/disable for the manual UI actions.',
|
||||
path: '/servers/{uuid}/cloudflare-tunnel',
|
||||
operationId: 'update-server-cloudflare-tunnel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_cloudflare_tunnel', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Cloudflare Tunnel settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if ($server->isLocalhost()) {
|
||||
return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_cloudflare_tunnel' => 'required|boolean',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$enabled = $request->boolean('is_cloudflare_tunnel');
|
||||
$server->settings->is_cloudflare_tunnel = $enabled;
|
||||
$server->settings->save();
|
||||
|
||||
if (! $enabled && $server->ip_previous) {
|
||||
$server->update(['ip' => $server->ip_previous]);
|
||||
}
|
||||
|
||||
auditLog('api.server.cloudflare_tunnel.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'is_cloudflare_tunnel' => $enabled,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($server->refresh()));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Enable Cloudflare Tunnel (manual)',
|
||||
description: 'Manually mark Cloudflare Tunnel as enabled for a server (matches UI manual enable). Does not deploy cloudflared remotely.',
|
||||
path: '/servers/{uuid}/cloudflare-tunnel/enable',
|
||||
operationId: 'enable-server-cloudflare-tunnel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloudflare Tunnel enabled.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function enable(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if ($server->isLocalhost()) {
|
||||
return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422);
|
||||
}
|
||||
|
||||
$server->settings->is_cloudflare_tunnel = true;
|
||||
$server->settings->save();
|
||||
|
||||
auditLog('api.server.cloudflare_tunnel.enabled', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Cloudflare Tunnel enabled.',
|
||||
...$this->transform($server->refresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Disable Cloudflare Tunnel',
|
||||
description: 'Mark Cloudflare Tunnel as disabled and restore ip_previous when available. Does not remove the remote cloudflared container.',
|
||||
path: '/servers/{uuid}/cloudflare-tunnel/disable',
|
||||
operationId: 'disable-server-cloudflare-tunnel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cloudflare Tunnel disabled.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function disable(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if ($server->isLocalhost()) {
|
||||
return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422);
|
||||
}
|
||||
|
||||
$server->settings->is_cloudflare_tunnel = false;
|
||||
$server->settings->save();
|
||||
|
||||
$message = 'Cloudflare Tunnel disabled.';
|
||||
if ($server->ip_previous) {
|
||||
$server->update(['ip' => $server->ip_previous]);
|
||||
$message .= ' Server IP restored to its previous IP address.';
|
||||
} else {
|
||||
$message .= ' Action required: Update the server IP address to its real IP address if needed.';
|
||||
}
|
||||
|
||||
auditLog('api.server.cloudflare_tunnel.disabled', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
...$this->transform($server->refresh()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerDockerCleanupController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'docker_cleanup_frequency',
|
||||
'docker_cleanup_threshold',
|
||||
'force_docker_cleanup',
|
||||
'delete_unused_volumes',
|
||||
'delete_unused_networks',
|
||||
'disable_application_image_retention',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
private function transform(Server $server): array
|
||||
{
|
||||
$settings = $server->settings;
|
||||
|
||||
return [
|
||||
'docker_cleanup_frequency' => $settings->docker_cleanup_frequency,
|
||||
'docker_cleanup_threshold' => (int) $settings->docker_cleanup_threshold,
|
||||
'force_docker_cleanup' => (bool) $settings->force_docker_cleanup,
|
||||
'delete_unused_volumes' => (bool) $settings->delete_unused_volumes,
|
||||
'delete_unused_networks' => (bool) $settings->delete_unused_networks,
|
||||
'disable_application_image_retention' => (bool) $settings->disable_application_image_retention,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Docker cleanup settings',
|
||||
description: 'Get Docker cleanup settings for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/docker-cleanup',
|
||||
operationId: 'get-server-docker-cleanup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Docker cleanup settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'docker_cleanup_frequency', type: 'string'),
|
||||
new OA\Property(property: 'docker_cleanup_threshold', type: 'integer'),
|
||||
new OA\Property(property: 'force_docker_cleanup', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_volumes', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_networks', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_application_image_retention', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Docker cleanup settings',
|
||||
description: 'Update Docker cleanup settings for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/docker-cleanup',
|
||||
operationId: 'update-server-docker-cleanup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'docker_cleanup_frequency', type: 'string', description: 'Cron / human frequency expression.'),
|
||||
new OA\Property(property: 'docker_cleanup_threshold', type: 'integer', minimum: 1, maximum: 99),
|
||||
new OA\Property(property: 'force_docker_cleanup', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_volumes', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_networks', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_application_image_retention', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Updated Docker cleanup settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'docker_cleanup_frequency', type: 'string'),
|
||||
new OA\Property(property: 'docker_cleanup_threshold', type: 'integer'),
|
||||
new OA\Property(property: 'force_docker_cleanup', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_volumes', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_networks', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_application_image_retention', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'docker_cleanup_frequency' => 'string',
|
||||
'docker_cleanup_threshold' => 'integer|min:1|max:99',
|
||||
'force_docker_cleanup' => 'boolean',
|
||||
'delete_unused_volumes' => 'boolean',
|
||||
'delete_unused_networks' => 'boolean',
|
||||
'disable_application_image_retention' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->has('docker_cleanup_frequency') && ! validate_cron_expression($request->docker_cleanup_frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_cleanup_frequency' => ['Invalid Cron / Human expression for Docker Cleanup Frequency.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
foreach (self::ALLOWED_FIELDS as $field) {
|
||||
if ($request->has($field)) {
|
||||
$settings->{$field} = $request->input($field);
|
||||
}
|
||||
}
|
||||
$settings->save();
|
||||
|
||||
auditLog('api.server.docker_cleanup.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($server->refresh()));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Run Docker cleanup',
|
||||
description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/docker-cleanup/run',
|
||||
operationId: 'run-server-docker-cleanup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: false,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'delete_unused_volumes', type: 'boolean'),
|
||||
new OA\Property(property: 'delete_unused_networks', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Docker cleanup job dispatched.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Manual cleanup job started.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function run(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'delete_unused_volumes' => 'boolean',
|
||||
'delete_unused_networks' => 'boolean',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), ['delete_unused_volumes', 'delete_unused_networks']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$deleteUnusedVolumes = $request->has('delete_unused_volumes')
|
||||
? $request->boolean('delete_unused_volumes')
|
||||
: (bool) $server->settings->delete_unused_volumes;
|
||||
$deleteUnusedNetworks = $request->has('delete_unused_networks')
|
||||
? $request->boolean('delete_unused_networks')
|
||||
: (bool) $server->settings->delete_unused_networks;
|
||||
|
||||
DockerCleanupJob::dispatch($server, true, $deleteUnusedVolumes, $deleteUnusedNetworks);
|
||||
|
||||
auditLog('api.server.docker_cleanup.run', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'delete_unused_volumes' => $deleteUnusedVolumes,
|
||||
'delete_unused_networks' => $deleteUnusedNetworks,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Manual cleanup job started. Depending on the amount of data, this might take a while.',
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Docker cleanup executions',
|
||||
description: 'List recent Docker cleanup execution logs for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/docker-cleanup/executions',
|
||||
operationId: 'list-server-docker-cleanup-executions',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Recent Docker cleanup executions.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'status', type: 'string'),
|
||||
new OA\Property(property: 'message', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'finished_at', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'created_at', type: 'string'),
|
||||
new OA\Property(property: 'updated_at', type: 'string'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function executions(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
$executions = $server->dockerCleanupExecutions()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(20)
|
||||
->get()
|
||||
->map(fn ($execution) => [
|
||||
'uuid' => $execution->uuid,
|
||||
'status' => $execution->status,
|
||||
'message' => $execution->message,
|
||||
'finished_at' => $execution->finished_at,
|
||||
'created_at' => $execution->created_at,
|
||||
'updated_at' => $execution->updated_at,
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json($executions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Server\StopLogDrain;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerLogDrainsController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_logdrain_newrelic_enabled',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_newrelic_base_uri',
|
||||
'is_logdrain_axiom_enabled',
|
||||
'logdrain_axiom_dataset_name',
|
||||
'logdrain_axiom_api_key',
|
||||
'is_logdrain_custom_enabled',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
private function canReadSensitive(): bool
|
||||
{
|
||||
return request()->attributes->get('can_read_sensitive', false) === true;
|
||||
}
|
||||
|
||||
private function transform(Server $server): array
|
||||
{
|
||||
$settings = $server->settings;
|
||||
$payload = [
|
||||
'is_logdrain_newrelic_enabled' => (bool) $settings->is_logdrain_newrelic_enabled,
|
||||
'logdrain_newrelic_base_uri' => $settings->logdrain_newrelic_base_uri,
|
||||
'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled,
|
||||
'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name,
|
||||
'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled,
|
||||
];
|
||||
|
||||
if ($this->canReadSensitive()) {
|
||||
$payload['logdrain_newrelic_license_key'] = $settings->logdrain_newrelic_license_key;
|
||||
$payload['logdrain_axiom_api_key'] = $settings->logdrain_axiom_api_key;
|
||||
$payload['logdrain_custom_config'] = $settings->logdrain_custom_config;
|
||||
$payload['logdrain_custom_config_parser'] = $settings->logdrain_custom_config_parser;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get log drain settings',
|
||||
description: 'Get log drain settings for a server owned by the authenticated team. Sensitive fields require the read:sensitive or root token ability.',
|
||||
path: '/servers/{uuid}/log-drains',
|
||||
operationId: 'get-server-log-drains',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Log drain settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_logdrain_newrelic_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_newrelic_license_key', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update log drain settings',
|
||||
description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.',
|
||||
path: '/servers/{uuid}/log-drains',
|
||||
operationId: 'update-server-log-drains',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_logdrain_newrelic_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_newrelic_license_key', type: 'string'),
|
||||
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string'),
|
||||
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string'),
|
||||
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string'),
|
||||
new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'logdrain_custom_config', type: 'string'),
|
||||
new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated log drain settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_logdrain_newrelic_enabled' => 'boolean',
|
||||
'logdrain_newrelic_license_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logdrain_newrelic_base_uri' => 'nullable|url',
|
||||
'is_logdrain_axiom_enabled' => 'boolean',
|
||||
'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'is_logdrain_custom_enabled' => 'boolean',
|
||||
'logdrain_custom_config' => 'nullable|string',
|
||||
'logdrain_custom_config_parser' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
foreach (self::ALLOWED_FIELDS as $field) {
|
||||
if ($request->has($field)) {
|
||||
$settings->{$field} = $request->input($field);
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional required fields when enabling a drain type (matches Livewire).
|
||||
if ($settings->is_logdrain_newrelic_enabled) {
|
||||
$errors = [];
|
||||
if (blank($settings->logdrain_newrelic_license_key)) {
|
||||
$errors['logdrain_newrelic_license_key'] = ['The New Relic license key is required when New Relic log drain is enabled.'];
|
||||
}
|
||||
if (blank($settings->logdrain_newrelic_base_uri)) {
|
||||
$errors['logdrain_newrelic_base_uri'] = ['The New Relic base URI is required when New Relic log drain is enabled.'];
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
}
|
||||
if ($settings->is_logdrain_axiom_enabled) {
|
||||
$errors = [];
|
||||
if (blank($settings->logdrain_axiom_dataset_name)) {
|
||||
$errors['logdrain_axiom_dataset_name'] = ['The Axiom dataset name is required when Axiom log drain is enabled.'];
|
||||
}
|
||||
if (blank($settings->logdrain_axiom_api_key)) {
|
||||
$errors['logdrain_axiom_api_key'] = ['The Axiom API key is required when Axiom log drain is enabled.'];
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
}
|
||||
if ($settings->is_logdrain_custom_enabled && blank($settings->logdrain_custom_config)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'logdrain_custom_config' => ['The custom log drain config is required when custom log drain is enabled.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
$server->refresh();
|
||||
|
||||
// Match Livewire instantSave: start or stop the drain service after settings change.
|
||||
if ($server->isLogDrainEnabled()) {
|
||||
StartLogDrain::dispatch($server);
|
||||
} else {
|
||||
StopLogDrain::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.log_drains.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerProxyController extends Controller
|
||||
{
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
private function canReadSensitive(): bool
|
||||
{
|
||||
return request()->attributes->get('can_read_sensitive', false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* proxy_type: string|null,
|
||||
* status: string|null,
|
||||
* redirect_enabled: bool,
|
||||
* redirect_url: string|null,
|
||||
* generate_exact_labels: bool,
|
||||
* configuration?: string|null
|
||||
* }
|
||||
*/
|
||||
private function payload(Server $server, bool $includeConfiguration = true): array
|
||||
{
|
||||
$payload = [
|
||||
'proxy_type' => $server->proxyType(),
|
||||
'status' => data_get($server->proxy, 'status'),
|
||||
'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true),
|
||||
'redirect_url' => data_get($server->proxy, 'redirect_url'),
|
||||
'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false),
|
||||
];
|
||||
|
||||
// Proxy compose can contain secrets; only expose with read:sensitive (and admin) like other APIs.
|
||||
if ($includeConfiguration && $this->canReadSensitive()) {
|
||||
// Prefer DB-stored config only — never SSH or regenerate for GET.
|
||||
$configuration = $server->proxy->get('last_saved_proxy_configuration');
|
||||
$payload['configuration'] = filled($configuration) ? $configuration : null;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get server proxy',
|
||||
description: 'Get proxy settings for a server owned by the authenticated team. The raw proxy configuration is only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner, and only when already stored in the database (no remote fetch).',
|
||||
path: '/servers/{uuid}/proxy',
|
||||
operationId: 'get-server-proxy',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Server proxy settings.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'proxy_type', type: 'string', nullable: true, example: 'TRAEFIK'),
|
||||
new OA\Property(property: 'status', type: 'string', nullable: true, example: 'running'),
|
||||
new OA\Property(property: 'redirect_enabled', type: 'boolean', example: true),
|
||||
new OA\Property(property: 'redirect_url', type: 'string', nullable: true, example: 'https://example.com'),
|
||||
new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false),
|
||||
new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->payload($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update server proxy',
|
||||
description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.',
|
||||
path: '/servers/{uuid}/proxy',
|
||||
operationId: 'update-server-proxy',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'redirect_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'),
|
||||
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
|
||||
new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Proxy settings updated.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'proxy_type', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'status', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'redirect_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'redirect_url', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
|
||||
new OA\Property(property: 'configuration', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'proxy_type'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redirect_enabled' => 'boolean',
|
||||
'redirect_url' => ['nullable', 'string', new SafeExternalUrl],
|
||||
'generate_exact_labels' => 'boolean',
|
||||
'proxy_type' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if ($request->has('proxy_type') && filled($request->proxy_type)) {
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(fn (ProxyTypes $type) => str($type->value)->lower());
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['proxy_type' => ['Invalid proxy type.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$changedFields = array_values(array_intersect($allowedFields, array_keys($request->all())));
|
||||
$redirectChanged = false;
|
||||
|
||||
if ($request->has('redirect_enabled')) {
|
||||
$server->proxy->redirect_enabled = $request->boolean('redirect_enabled');
|
||||
$redirectChanged = true;
|
||||
}
|
||||
|
||||
if ($request->exists('redirect_url')) {
|
||||
$server->proxy->redirect_url = $request->input('redirect_url') ?: null;
|
||||
$redirectChanged = true;
|
||||
}
|
||||
|
||||
if ($redirectChanged) {
|
||||
$server->save();
|
||||
}
|
||||
|
||||
if ($request->has('generate_exact_labels')) {
|
||||
$server->settings->generate_exact_labels = $request->boolean('generate_exact_labels');
|
||||
$server->settings->save();
|
||||
}
|
||||
|
||||
if ($request->has('proxy_type') && filled($request->proxy_type)) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
$server->refresh();
|
||||
}
|
||||
|
||||
// Apply redirect file on the server only when reachable (DB settings always saved above).
|
||||
if ($redirectChanged && $server->isFunctional()) {
|
||||
$server->setupDefaultRedirect();
|
||||
}
|
||||
|
||||
auditLog('api.server.proxy.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => $changedFields,
|
||||
]);
|
||||
|
||||
return response()->json($this->payload($server->fresh()));
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
summary: 'Save server proxy configuration',
|
||||
description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).',
|
||||
path: '/servers/{uuid}/proxy/configuration',
|
||||
operationId: 'save-server-proxy-configuration',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['configuration'],
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'configuration',
|
||||
type: 'string',
|
||||
description: 'Proxy docker-compose YAML. Prefer base64 encoding for multi-line content.'
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Proxy configuration saved.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Proxy configuration saved.'),
|
||||
new OA\Property(property: 'proxy_type', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'status', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'redirect_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'redirect_url', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
|
||||
new OA\Property(property: 'configuration', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function saveConfiguration(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = ['configuration'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'configuration' => 'required|string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$configuration = $request->input('configuration');
|
||||
if (isBase64Encoded($configuration)) {
|
||||
$decoded = base64_decode($configuration, true);
|
||||
if ($decoded === false || mb_detect_encoding($decoded, 'UTF-8', true) === false) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'configuration' => ['The configuration should be valid base64-encoded UTF-8 text.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$configuration = $decoded;
|
||||
}
|
||||
|
||||
if (! filled(trim($configuration))) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'configuration' => ['The configuration field is required.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
SaveProxyConfiguration::run($server, $configuration);
|
||||
|
||||
auditLog('api.server.proxy.configuration_saved', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
$payload = $this->payload($server->fresh());
|
||||
$payload['message'] = 'Proxy configuration saved.';
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Restart server proxy',
|
||||
description: 'Queue a proxy restart for a team-owned server.',
|
||||
path: '/servers/{uuid}/proxy/restart',
|
||||
operationId: 'restart-server-proxy',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Proxy restart queued.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Proxy restart queued.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function restart(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('manageProxy', $server);
|
||||
|
||||
RestartProxyJob::dispatch($server);
|
||||
|
||||
auditLog('api.server.proxy.restarted', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Proxy restart queued.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerSentinelController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_sentinel_enabled',
|
||||
'is_metrics_enabled',
|
||||
'is_sentinel_debug_enabled',
|
||||
'sentinel_token',
|
||||
'sentinel_metrics_refresh_rate_seconds',
|
||||
'sentinel_metrics_history_days',
|
||||
'sentinel_push_interval_seconds',
|
||||
'sentinel_custom_url',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
private function canReadSensitive(): bool
|
||||
{
|
||||
return request()->attributes->get('can_read_sensitive', false) === true;
|
||||
}
|
||||
|
||||
private function transform(Server $server): array
|
||||
{
|
||||
$settings = $server->settings;
|
||||
$payload = [
|
||||
'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled,
|
||||
'is_metrics_enabled' => (bool) $settings->is_metrics_enabled,
|
||||
'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled,
|
||||
'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds,
|
||||
'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days,
|
||||
'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds,
|
||||
'sentinel_updated_at' => $server->sentinel_updated_at,
|
||||
];
|
||||
|
||||
if ($this->canReadSensitive()) {
|
||||
$payload['sentinel_token'] = $settings->sentinel_token;
|
||||
$payload['sentinel_custom_url'] = $settings->sentinel_custom_url;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Sentinel settings',
|
||||
description: 'Get Sentinel settings for a server owned by the authenticated team. sentinel_token and sentinel_custom_url require the read:sensitive or root token ability.',
|
||||
path: '/servers/{uuid}/sentinel',
|
||||
operationId: 'get-server-sentinel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Sentinel settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer'),
|
||||
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer'),
|
||||
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'),
|
||||
new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Sentinel settings',
|
||||
description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.',
|
||||
path: '/servers/{uuid}/sentinel',
|
||||
operationId: 'update-server-sentinel',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'sentinel_token', type: 'string'),
|
||||
new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10),
|
||||
new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated Sentinel settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = $this->findServerForTeam($teamId, $request->uuid);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_sentinel_enabled' => 'boolean',
|
||||
'is_metrics_enabled' => 'boolean',
|
||||
'is_sentinel_debug_enabled' => 'boolean',
|
||||
'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'],
|
||||
'sentinel_metrics_refresh_rate_seconds' => 'integer|min:1',
|
||||
'sentinel_metrics_history_days' => 'integer|min:1',
|
||||
'sentinel_push_interval_seconds' => 'integer|min:10',
|
||||
'sentinel_custom_url' => 'nullable|url',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->has('sentinel_token') && ! ServerSetting::isValidSentinelToken($request->input('sentinel_token'))) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['sentinel_token' => ['Invalid sentinel token characters.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
$enablingSentinel = $request->has('is_sentinel_enabled')
|
||||
&& $request->boolean('is_sentinel_enabled')
|
||||
&& ! $settings->is_sentinel_enabled;
|
||||
|
||||
if ($enablingSentinel && $server->isBuildServer()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
foreach (self::ALLOWED_FIELDS as $field) {
|
||||
if ($request->has($field)) {
|
||||
$settings->{$field} = $request->input($field);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel).
|
||||
if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) {
|
||||
$settings->is_metrics_enabled = false;
|
||||
$settings->is_sentinel_debug_enabled = false;
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
|
||||
auditLog('api.server.sentinel.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($server->refresh()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferImporter $importer,
|
||||
private ServerTransferClaimer $claimer,
|
||||
private ServerTransferMigrator $migrator,
|
||||
) {
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate server to another Coolify instance',
|
||||
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
|
||||
path: '/servers/{uuid}/migrate',
|
||||
operationId: 'migrate-server-between-instances',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['target_url', 'target_token'],
|
||||
properties: [
|
||||
new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'),
|
||||
new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Migrated'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, description: 'Validation or remote import failed'),
|
||||
]
|
||||
)]
|
||||
public function migrate(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'target_url' => 'required|string|url',
|
||||
'target_token' => 'required|string',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrator->migrate(
|
||||
server: $server,
|
||||
targetUrl: $request->string('target_url')->toString(),
|
||||
targetToken: $request->string('target_token')->toString(),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.migrate', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $result['export_id'],
|
||||
'target_url' => $result['target_url'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Export server transfer bundle',
|
||||
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.',
|
||||
path: '/servers/{uuid}/export',
|
||||
operationId: 'export-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transfer bundle'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function export(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
]);
|
||||
|
||||
if ($request->boolean('encrypt') && $request->filled('passphrase')) {
|
||||
return response()->json(
|
||||
ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($bundle);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'),
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
|
||||
new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Dry-run result'),
|
||||
new OA\Response(response: 201, description: 'Imported'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed'),
|
||||
]
|
||||
)]
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'bundle' => 'required|array',
|
||||
'passphrase' => 'string|nullable',
|
||||
'dry_run' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
'claim' => 'boolean|nullable',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bundle = $request->input('bundle', []);
|
||||
if (data_get($bundle, 'encrypted')) {
|
||||
if (! $request->filled('passphrase')) {
|
||||
return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422);
|
||||
}
|
||||
try {
|
||||
$bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
dryRun: $request->boolean('dry_run', false),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
claim: $request->boolean('claim', true),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$status = $e instanceof ValidationException ? 422 : 422;
|
||||
$payload = ['message' => $e->getMessage()];
|
||||
if ($e instanceof ValidationException) {
|
||||
$payload['errors'] = $e->errors();
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
|
||||
auditLog('api.server.import', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $result['server_uuid'],
|
||||
'export_id' => $result['export_id'],
|
||||
'dry_run' => $result['dry_run'],
|
||||
]);
|
||||
|
||||
return response()->json($result, $result['dry_run'] ? 200 : 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Claim imported server',
|
||||
description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.',
|
||||
path: '/servers/{uuid}/claim',
|
||||
operationId: 'claim-server',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claim result'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function claim(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->claim(
|
||||
$server,
|
||||
writeRemote: $request->boolean('write_remote', true),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.claim', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim_written' => $result['claim_written'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Mark server transferred',
|
||||
description: 'Source-instance step: disable automations after a successful export/import handoff.',
|
||||
path: '/servers/{uuid}/transfer/complete',
|
||||
operationId: 'complete-server-transfer',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'export_id', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'target_instance_url', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Marked transferred'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function complete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'export_id' => 'string|nullable',
|
||||
'target_instance_url' => 'string|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: $request->input('export_id'),
|
||||
targetInstanceUrl: $request->input('target_instance_url'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.transfer_complete', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $request->input('export_id'),
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Write transfer bundle to server mailbox',
|
||||
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.',
|
||||
path: '/servers/{uuid}/export/mailbox',
|
||||
operationId: 'export-server-transfer-mailbox',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mailbox write result'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function writeMailbox(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$result = $this->claimer->writeMailbox(
|
||||
$server,
|
||||
$bundle,
|
||||
$request->filled('passphrase') ? $request->string('passphrase')->toString() : null,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export_mailbox', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
'written' => $result['written'],
|
||||
'message' => $result['written']
|
||||
? 'Transfer bundle written to server mailbox.'
|
||||
: 'Failed to write mailbox on remote host.',
|
||||
], $result['written'] ? 200 : 422);
|
||||
}
|
||||
|
||||
private function canReadSensitive(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('can_read_sensitive', false);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@ use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
@@ -21,9 +24,14 @@ class ServersController extends Controller
|
||||
{
|
||||
private function removeSensitiveDataFromSettings($settings)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
$settings = $settings->makeHidden([
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$settings = $settings->makeVisible([
|
||||
'sentinel_token',
|
||||
'sentinel_custom_url',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -35,8 +43,11 @@ class ServersController extends Controller
|
||||
$server->makeHidden([
|
||||
'id',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
// Do nothing
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$server->makeVisible([
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_newrelic_license_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($server);
|
||||
@@ -146,6 +157,7 @@ class ServersController extends Controller
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
if ($with_resources) {
|
||||
$server['resources'] = $server->definedResources()->map(function ($resource) {
|
||||
$payload = [
|
||||
@@ -475,9 +487,10 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [ModelsServer::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -486,10 +499,12 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -564,6 +579,14 @@ class ServersController extends Controller
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'ip' => $server->ip,
|
||||
'is_build_server' => (bool) $request->is_build_server,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -603,6 +626,7 @@ class ServersController extends Controller
|
||||
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
|
||||
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
|
||||
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
|
||||
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -639,7 +663,7 @@ class ServersController extends Controller
|
||||
)]
|
||||
public function update_server(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -647,7 +671,7 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -656,7 +680,7 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
@@ -665,6 +689,10 @@ class ServersController extends Controller
|
||||
'deployment_queue_limit' => 'integer|min:1',
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
'connection_timeout' => 'integer|min:1|max:300',
|
||||
'is_terminal_enabled' => 'boolean|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -685,21 +713,22 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
if ($request->proxy_type) {
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||
return str($proxyType->value)->lower();
|
||||
});
|
||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
} else {
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||
}
|
||||
}
|
||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
||||
if ($request->is_build_server) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->is_build_server,
|
||||
]);
|
||||
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||
if ($request->filled('private_key_uuid')) {
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$updateFields['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
@@ -709,15 +738,46 @@ class ServersController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
|
||||
if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->boolean('is_build_server'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->has('is_terminal_enabled')) {
|
||||
$server->settings()->update([
|
||||
'is_terminal_enabled' => $request->boolean('is_terminal_enabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -791,13 +851,14 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $server);
|
||||
|
||||
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if ($server->definedResources()->count() > 0 && ! $force) {
|
||||
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
|
||||
}
|
||||
if ($server->isLocalhost()) {
|
||||
if ($server->is_coolify_host) {
|
||||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
@@ -807,19 +868,34 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$deletedUuid = $server->uuid;
|
||||
$deletedName = $server->name;
|
||||
$deletedIp = $server->ip;
|
||||
$server->delete();
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
false, // Don't delete from Hetzner via API
|
||||
$server->hetzner_server_id,
|
||||
$server->cloud_provider_token_id,
|
||||
$server->team_id
|
||||
$server->team_id,
|
||||
false, // Don't delete from Vultr via API
|
||||
$server->vultr_instance_id,
|
||||
false, // Don't delete from DigitalOcean via API
|
||||
$server->digitalocean_droplet_id
|
||||
);
|
||||
|
||||
auditLog('api.server.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $deletedUuid,
|
||||
'server_name' => $deletedName,
|
||||
'ip' => $deletedIp,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Validate',
|
||||
description: 'Validate server by UUID.',
|
||||
path: '/servers/{uuid}/validate',
|
||||
@@ -831,6 +907,19 @@ class ServersController extends Controller
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: false,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'install',
|
||||
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
@@ -879,8 +968,40 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
ValidateServer::dispatch($server);
|
||||
$this->authorize('update', $server);
|
||||
|
||||
return response()->json(['message' => 'Validation started.'], 201);
|
||||
if (! $server->canBeValidated()) {
|
||||
return response()->json([
|
||||
'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'install' => 'boolean',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$install = $request->boolean('install', false);
|
||||
if ($install) {
|
||||
ValidateAndInstallServerJob::dispatch($server);
|
||||
} else {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.validated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'install' => $install,
|
||||
]);
|
||||
|
||||
$message = $install ? 'Validation and installation started.' : 'Validation started.';
|
||||
|
||||
return response()->json(['message' => $message], 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Service\UpdateServiceApplicationFromApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceApplicationsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceApplication $serviceApplication): array
|
||||
{
|
||||
$serviceApplication->makeHidden([
|
||||
'id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
|
||||
$serialized = serializeApiResponse($serviceApplication);
|
||||
|
||||
if ($serialized instanceof Collection) {
|
||||
return $serialized->all();
|
||||
}
|
||||
|
||||
return (array) $serialized;
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)
|
||||
->whereUuid($uuid)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
|
||||
{
|
||||
$appUuid = $request->route('app_uuid');
|
||||
if (! $appUuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $service->applications()
|
||||
->where('uuid', $appUuid)
|
||||
->with(['service.destination.server'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function swarmNotSupportedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This operation is not supported for Swarm servers yet.',
|
||||
], 501);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List service applications',
|
||||
description: 'List compose service applications (containers) for a single service.',
|
||||
path: '/services/{uuid}/applications',
|
||||
operationId: 'list-service-applications-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service applications for this service.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$items = $service->applications()
|
||||
->get()
|
||||
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application',
|
||||
description: 'Get a single compose service application by service UUID and application UUID.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'get-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update service application',
|
||||
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'patch-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force_domain_override',
|
||||
in: 'query',
|
||||
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'url' => new OA\Property(
|
||||
property: 'url',
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
|
||||
),
|
||||
'noindex_domains' => new OA\Property(
|
||||
property: 'noindex_domains',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'string'),
|
||||
description: 'The subset of the service application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the domains are ignored.',
|
||||
nullable: true,
|
||||
),
|
||||
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
|
||||
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
|
||||
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
|
||||
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
|
||||
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Updated service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts (unless force_domain_override).',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $serviceApplication);
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (empty($payload)) {
|
||||
$payload = $request->request->all();
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'url',
|
||||
'noindex_domains',
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
'exclude_from_status',
|
||||
'is_log_drain_enabled',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
];
|
||||
|
||||
$validationRules = [
|
||||
'url' => 'nullable|string',
|
||||
'noindex_domains' => 'sometimes|array|nullable',
|
||||
'noindex_domains.*' => 'string',
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'nullable|string',
|
||||
'exclude_from_status' => 'sometimes|boolean',
|
||||
'is_log_drain_enabled' => 'sometimes|boolean',
|
||||
'is_gzip_enabled' => 'sometimes|boolean',
|
||||
'is_stripprefix_enabled' => 'sometimes|boolean',
|
||||
];
|
||||
|
||||
$validator = Validator::make($payload, $validationRules);
|
||||
|
||||
$extraFields = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
|
||||
if ($response instanceof JsonResponse) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$serviceApplication->refresh();
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application logs',
|
||||
description: 'Get Docker logs for a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/logs',
|
||||
operationId: 'get-service-application-logs-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Logs.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'logs' => new OA\Property(property: 'logs', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Get service application logs',
|
||||
description: 'Get Docker logs for a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/logs',
|
||||
operationId: 'post-service-application-logs-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Logs.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'logs', type: 'string')],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function logs_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
|
||||
|
||||
$status = getContainerStatus($server, $containerName);
|
||||
if ($status !== 'running') {
|
||||
return response()->json([
|
||||
'message' => 'Service application container is not running.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
$logs = getContainerLogs($server, $containerName, $lines);
|
||||
|
||||
return response()->json([
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start or redeploy service application container',
|
||||
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/start',
|
||||
operationId: 'post-start-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deploy request queued.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', type: 'string')],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_start(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$pullLatest = $request->boolean('latest', false);
|
||||
$forceRebuild = $request->boolean('force', false);
|
||||
|
||||
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application deploy request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Restart service application container',
|
||||
description: 'Restarts a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/restart',
|
||||
operationId: 'post-restart-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restart queued.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', type: 'string')],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_restart(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
RestartServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application restart request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Stop service application container',
|
||||
description: 'Stops a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/stop',
|
||||
operationId: 'post-stop-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Stop queued.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', type: 'string')],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_stop(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
StopServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application stop request queued.',
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceDatabasesController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
|
||||
{
|
||||
$serviceDatabase->makeHidden([
|
||||
'id',
|
||||
'service',
|
||||
'service_id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
|
||||
$serialized = serializeApiResponse($serviceDatabase);
|
||||
|
||||
if ($serialized instanceof Collection) {
|
||||
return $serialized->all();
|
||||
}
|
||||
|
||||
return (array) $serialized;
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)
|
||||
->whereUuid($request->route('uuid'))
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase
|
||||
{
|
||||
return $service->databases()
|
||||
->where('uuid', $request->route('database_uuid'))
|
||||
->with(['service.destination.server'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function swarmNotSupportedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This operation is not supported for Swarm servers yet.',
|
||||
], 501);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List service databases',
|
||||
description: 'List compose databases for a single service.',
|
||||
path: '/services/{uuid}/databases',
|
||||
operationId: 'list-service-databases-by-service-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$databases = $service->databases()
|
||||
->get()
|
||||
->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database));
|
||||
|
||||
return response()->json($databases);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service database',
|
||||
description: 'Get a compose database by service UUID and database UUID.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}',
|
||||
operationId: 'get-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceDatabase);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceDatabase));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update service database',
|
||||
description: 'Update mutable fields for a compose service database.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}',
|
||||
operationId: 'patch-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'image', type: 'string'),
|
||||
new OA\Property(property: 'exclude_from_status', type: 'boolean'),
|
||||
new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_public', type: 'boolean'),
|
||||
new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535),
|
||||
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1),
|
||||
],
|
||||
additionalProperties: false,
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$invalidRequest = validateIncomingRequest($request);
|
||||
if ($invalidRequest instanceof JsonResponse) {
|
||||
return $invalidRequest;
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $serviceDatabase);
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (empty($payload)) {
|
||||
$payload = $request->request->all();
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
'exclude_from_status',
|
||||
'is_log_drain_enabled',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'public_port_timeout',
|
||||
];
|
||||
$validator = Validator::make($payload, [
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'sometimes|string',
|
||||
'exclude_from_status' => 'sometimes|boolean',
|
||||
'is_log_drain_enabled' => 'sometimes|boolean',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'public_port' => 'nullable|integer|min:1|max:65535',
|
||||
'public_port_timeout' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server = $serviceDatabase->service->destination->server;
|
||||
if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$isPublic = $payload['is_public'] ?? $serviceDatabase->is_public;
|
||||
$publicPort = $payload['public_port'] ?? $serviceDatabase->public_port;
|
||||
if ($isPublic && ! $publicPort) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['public_port' => ['A public port is required when the database is public.']],
|
||||
], 422);
|
||||
}
|
||||
if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) {
|
||||
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
||||
}
|
||||
|
||||
$shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public;
|
||||
$shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public;
|
||||
|
||||
$serviceDatabase->fill($payload);
|
||||
$serviceDatabase->save();
|
||||
$serviceDatabase->refresh();
|
||||
updateCompose($serviceDatabase);
|
||||
|
||||
if ($shouldStartProxy) {
|
||||
StartDatabaseProxy::dispatch($serviceDatabase);
|
||||
} elseif ($shouldStopProxy) {
|
||||
StopDatabaseProxy::dispatch($serviceDatabase);
|
||||
}
|
||||
|
||||
auditLog('api.service_database.updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_database_uuid' => $serviceDatabase->uuid,
|
||||
'changed_fields' => array_keys($payload),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceDatabase));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service database logs',
|
||||
description: 'Get Docker logs for a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/logs',
|
||||
operationId: 'get-service-database-logs-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function logs(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'view');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase, $server] = $resolved;
|
||||
$containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid;
|
||||
if (getContainerStatus($server, $containerName) !== 'running') {
|
||||
return response()->json(['message' => 'Service database container is not running.'], 400);
|
||||
}
|
||||
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
|
||||
return response()->json([
|
||||
'logs' => getContainerLogs($server, $containerName, $lines),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start or redeploy service database container',
|
||||
description: 'Run docker compose up for a single compose database.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/start',
|
||||
operationId: 'start-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
DeployServiceApplication::dispatch(
|
||||
$serviceDatabase,
|
||||
$request->boolean('latest'),
|
||||
$request->boolean('force'),
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Service database deploy request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Restart service database container',
|
||||
description: 'Restart a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/restart',
|
||||
operationId: 'restart-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function restart(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
RestartServiceApplication::dispatch($serviceDatabase);
|
||||
|
||||
return response()->json(['message' => 'Service database restart request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Stop service database container',
|
||||
description: 'Stop a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/stop',
|
||||
operationId: 'stop-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', type: 'string')])),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function stop(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
StopServiceApplication::dispatch($serviceDatabase);
|
||||
|
||||
return response()->json(['message' => 'Service database stop request queued.']);
|
||||
}
|
||||
|
||||
private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize($ability, $serviceDatabase);
|
||||
|
||||
$server = $serviceDatabase->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json(['message' => 'Server is not functional.'], 400);
|
||||
}
|
||||
|
||||
return [$serviceDatabase, $server];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,907 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class SharedEnvironmentVariablesController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
|
||||
|
||||
private function removeSensitiveData(SharedEnvironmentVariable $env): mixed
|
||||
{
|
||||
$env->makeHidden([
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
'version',
|
||||
]);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$env->makeVisible(['value']);
|
||||
}
|
||||
|
||||
if ($env->is_shown_once ?? false) {
|
||||
$env->makeHidden(['value']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($env);
|
||||
}
|
||||
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
private function validateEnvPayload(Request $request, bool $requireKey = true): JsonResponse|true
|
||||
{
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(required: $requireKey),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $requireKey && $request->all() === []) {
|
||||
return response()->json(['message' => 'At least one field must be provided.'], 422);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findEnvInScope(int $teamId, int|string $envId, string $type, array $scope = []): ?SharedEnvironmentVariable
|
||||
{
|
||||
$query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId)
|
||||
->where('type', $type)
|
||||
->where('id', $envId);
|
||||
|
||||
if (array_key_exists('project_id', $scope)) {
|
||||
$query->where('project_id', $scope['project_id']);
|
||||
}
|
||||
if (array_key_exists('environment_id', $scope)) {
|
||||
$query->where('environment_id', $scope['environment_id']);
|
||||
}
|
||||
if (array_key_exists('server_id', $scope)) {
|
||||
$query->where('server_id', $scope['server_id']);
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
private function keyExistsInScope(int $teamId, string $key, string $type, array $scope = [], ?int $exceptId = null): bool
|
||||
{
|
||||
$query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId)
|
||||
->where('type', $type)
|
||||
->where('key', $key);
|
||||
|
||||
if (array_key_exists('project_id', $scope)) {
|
||||
$query->where('project_id', $scope['project_id']);
|
||||
} else {
|
||||
$query->whereNull('project_id');
|
||||
}
|
||||
if (array_key_exists('environment_id', $scope)) {
|
||||
$query->where('environment_id', $scope['environment_id']);
|
||||
} else {
|
||||
$query->whereNull('environment_id');
|
||||
}
|
||||
if (array_key_exists('server_id', $scope)) {
|
||||
$query->where('server_id', $scope['server_id']);
|
||||
} else {
|
||||
$query->whereNull('server_id');
|
||||
}
|
||||
|
||||
if ($exceptId !== null) {
|
||||
$query->where('id', '!=', $exceptId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
private function listEnvs(int $teamId, string $type, array $scope = []): JsonResponse
|
||||
{
|
||||
$query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId)
|
||||
->where('type', $type)
|
||||
->orderBy('id');
|
||||
|
||||
if (array_key_exists('project_id', $scope)) {
|
||||
$query->where('project_id', $scope['project_id']);
|
||||
}
|
||||
if (array_key_exists('environment_id', $scope)) {
|
||||
$query->where('environment_id', $scope['environment_id']);
|
||||
}
|
||||
if (array_key_exists('server_id', $scope)) {
|
||||
$query->where('server_id', $scope['server_id']);
|
||||
}
|
||||
|
||||
$envs = $query->get()->map(fn (SharedEnvironmentVariable $env) => $this->removeSensitiveData($env));
|
||||
|
||||
return response()->json($envs);
|
||||
}
|
||||
|
||||
private function createEnv(Request $request, int $teamId, string $type, array $attributes = []): JsonResponse
|
||||
{
|
||||
$validated = $this->validateEnvPayload($request, requireKey: true);
|
||||
if ($validated instanceof JsonResponse) {
|
||||
return $validated;
|
||||
}
|
||||
|
||||
$this->authorize('create', SharedEnvironmentVariable::class);
|
||||
|
||||
$scope = array_filter([
|
||||
'project_id' => $attributes['project_id'] ?? null,
|
||||
'environment_id' => $attributes['environment_id'] ?? null,
|
||||
'server_id' => $attributes['server_id'] ?? null,
|
||||
], fn ($value) => ! is_null($value));
|
||||
|
||||
if ($this->keyExistsInScope($teamId, $request->key, $type, $scope)) {
|
||||
return response()->json([
|
||||
'message' => 'Environment variable already exists. Use PATCH request to update it.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$env = SharedEnvironmentVariable::create([
|
||||
'key' => $request->key,
|
||||
'value' => $request->value,
|
||||
'is_literal' => $request->boolean('is_literal'),
|
||||
'is_multiline' => $request->boolean('is_multiline'),
|
||||
'is_shown_once' => $request->boolean('is_shown_once'),
|
||||
'comment' => $request->comment,
|
||||
'type' => $type,
|
||||
'team_id' => $teamId,
|
||||
'project_id' => $attributes['project_id'] ?? null,
|
||||
'environment_id' => $attributes['environment_id'] ?? null,
|
||||
'server_id' => $attributes['server_id'] ?? null,
|
||||
]);
|
||||
|
||||
auditLog('api.shared_env.created', [
|
||||
'team_id' => $teamId,
|
||||
'env_id' => $env->id,
|
||||
'env_key' => $env->key,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $env->id,
|
||||
], 201);
|
||||
}
|
||||
|
||||
private function updateEnv(Request $request, int $teamId, int|string $envId, string $type, array $scope = []): JsonResponse
|
||||
{
|
||||
$env = $this->findEnvInScope($teamId, $envId, $type, $scope);
|
||||
if (! $env) {
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $env);
|
||||
|
||||
$validated = $this->validateEnvPayload($request, requireKey: false);
|
||||
if ($validated instanceof JsonResponse) {
|
||||
return $validated;
|
||||
}
|
||||
|
||||
if ($request->has('key') && $request->key !== $env->key) {
|
||||
if ($this->keyExistsInScope($teamId, $request->key, $type, $scope, exceptId: $env->id)) {
|
||||
return response()->json([
|
||||
'message' => 'Environment variable already exists with this key.',
|
||||
], 409);
|
||||
}
|
||||
$env->key = $request->key;
|
||||
}
|
||||
|
||||
if ($request->has('value')) {
|
||||
$env->value = $request->value;
|
||||
}
|
||||
if ($request->has('is_literal')) {
|
||||
$env->is_literal = $request->boolean('is_literal');
|
||||
}
|
||||
if ($request->has('is_multiline')) {
|
||||
$env->is_multiline = $request->boolean('is_multiline');
|
||||
}
|
||||
if ($request->has('is_shown_once')) {
|
||||
$env->is_shown_once = $request->boolean('is_shown_once');
|
||||
}
|
||||
if ($request->has('comment')) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
|
||||
$env->save();
|
||||
|
||||
auditLog('api.shared_env.updated', [
|
||||
'team_id' => $teamId,
|
||||
'env_id' => $env->id,
|
||||
'env_key' => $env->key,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env->fresh()));
|
||||
}
|
||||
|
||||
private function deleteEnv(int $teamId, int|string $envId, string $type, array $scope = []): JsonResponse
|
||||
{
|
||||
$env = $this->findEnvInScope($teamId, $envId, $type, $scope);
|
||||
if (! $env) {
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $env);
|
||||
|
||||
$envKey = $env->key;
|
||||
$envIdValue = $env->id;
|
||||
$env->delete();
|
||||
|
||||
auditLog('api.shared_env.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'env_id' => $envIdValue,
|
||||
'env_key' => $envKey,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Environment variable deleted.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveProject(int $teamId, string $uuid): Project|JsonResponse
|
||||
{
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
private function resolveServer(int $teamId, string $uuid): Server|JsonResponse
|
||||
{
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function resolveEnvironment(Project $project, string $environmentNameOrUuid): Environment|JsonResponse
|
||||
{
|
||||
$environment = $project->environments()->whereName($environmentNameOrUuid)->first();
|
||||
if (! $environment) {
|
||||
$environment = $project->environments()->whereUuid($environmentNameOrUuid)->first();
|
||||
}
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
// ── Team ──────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Team Shared Envs',
|
||||
description: 'List shared environment variables for the current team (type=team).',
|
||||
path: '/team/envs',
|
||||
operationId: 'list-team-shared-envs',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Team shared environment variables.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
],
|
||||
)]
|
||||
public function team_envs(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$this->authorize('viewAny', SharedEnvironmentVariable::class);
|
||||
|
||||
return $this->listEnvs($teamId, 'team');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Team Shared Env',
|
||||
description: 'Create a shared environment variable for the current team (type=team).',
|
||||
path: '/team/envs',
|
||||
operationId: 'create-team-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['key'],
|
||||
properties: [
|
||||
new OA\Property(property: 'key', type: 'string'),
|
||||
new OA\Property(property: 'value', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'is_literal', type: 'boolean'),
|
||||
new OA\Property(property: 'is_multiline', type: 'boolean'),
|
||||
new OA\Property(property: 'is_shown_once', type: 'boolean'),
|
||||
new OA\Property(property: 'comment', type: 'string', nullable: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Environment variable created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 409, description: 'Environment variable already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function team_create_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
return $this->createEnv($request, $teamId, 'team');
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Team Shared Env',
|
||||
description: 'Update a team shared environment variable by id.',
|
||||
path: '/team/envs/{env_id}',
|
||||
operationId: 'update-team-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function team_update_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
return $this->updateEnv($request, $teamId, $request->route('env_id'), 'team');
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Team Shared Env',
|
||||
description: 'Delete a team shared environment variable by id.',
|
||||
path: '/team/envs/{env_id}',
|
||||
operationId: 'delete-team-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function team_delete_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
return $this->deleteEnv($teamId, $request->route('env_id'), 'team');
|
||||
}
|
||||
|
||||
// ── Project ───────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Project Shared Envs',
|
||||
description: 'List shared environment variables for a project (type=project).',
|
||||
path: '/projects/{uuid}/envs',
|
||||
operationId: 'list-project-shared-envs',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Project shared environment variables.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function project_envs(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->listEnvs($teamId, 'project', ['project_id' => $project->id]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Project Shared Env',
|
||||
description: 'Create a shared environment variable for a project (type=project).',
|
||||
path: '/projects/{uuid}/envs',
|
||||
operationId: 'create-project-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Environment variable created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Environment variable already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function project_create_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->createEnv($request, $teamId, 'project', ['project_id' => $project->id]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Project Shared Env',
|
||||
description: 'Update a project shared environment variable by id.',
|
||||
path: '/projects/{uuid}/envs/{env_id}',
|
||||
operationId: 'update-project-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function project_update_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->updateEnv(
|
||||
$request,
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'project',
|
||||
['project_id' => $project->id],
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Project Shared Env',
|
||||
description: 'Delete a project shared environment variable by id.',
|
||||
path: '/projects/{uuid}/envs/{env_id}',
|
||||
operationId: 'delete-project-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function project_delete_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->deleteEnv(
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'project',
|
||||
['project_id' => $project->id],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Environment ───────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Environment Shared Envs',
|
||||
description: 'List shared environment variables for a project environment (type=environment).',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs',
|
||||
operationId: 'list-environment-shared-envs',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment shared environment variables.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function environment_envs(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid'));
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Environment Shared Env',
|
||||
description: 'Create a shared environment variable for a project environment (type=environment).',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs',
|
||||
operationId: 'create-environment-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Environment variable created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Environment variable already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function environment_create_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid'));
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->createEnv($request, $teamId, 'environment', ['environment_id' => $environment->id]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Environment Shared Env',
|
||||
description: 'Update an environment shared environment variable by id.',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}',
|
||||
operationId: 'update-environment-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function environment_update_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid'));
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->updateEnv(
|
||||
$request,
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'environment',
|
||||
['environment_id' => $environment->id],
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Environment Shared Env',
|
||||
description: 'Delete an environment shared environment variable by id.',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}',
|
||||
operationId: 'delete-environment-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function environment_delete_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$project = $this->resolveProject($teamId, $request->route('uuid'));
|
||||
if ($project instanceof JsonResponse) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
$environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid'));
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return $this->deleteEnv(
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'environment',
|
||||
['environment_id' => $environment->id],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Server ────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Server Shared Envs',
|
||||
description: 'List shared environment variables for a server (type=server).',
|
||||
path: '/servers/{uuid}/envs',
|
||||
operationId: 'list-server-shared-envs',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Server shared environment variables.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function server_envs(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->resolveServer($teamId, $request->route('uuid'));
|
||||
if ($server instanceof JsonResponse) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return $this->listEnvs($teamId, 'server', ['server_id' => $server->id]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Server Shared Env',
|
||||
description: 'Create a shared environment variable for a server (type=server).',
|
||||
path: '/servers/{uuid}/envs',
|
||||
operationId: 'create-server-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Environment variable created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Environment variable already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function server_create_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->resolveServer($teamId, $request->route('uuid'));
|
||||
if ($server instanceof JsonResponse) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return $this->createEnv($request, $teamId, 'server', ['server_id' => $server->id]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Server Shared Env',
|
||||
description: 'Update a server shared environment variable by id.',
|
||||
path: '/servers/{uuid}/envs/{env_id}',
|
||||
operationId: 'update-server-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function server_update_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->resolveServer($teamId, $request->route('uuid'));
|
||||
if ($server instanceof JsonResponse) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return $this->updateEnv(
|
||||
$request,
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'server',
|
||||
['server_id' => $server->id],
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Server Shared Env',
|
||||
description: 'Delete a server shared environment variable by id.',
|
||||
path: '/servers/{uuid}/envs/{env_id}',
|
||||
operationId: 'delete-server-shared-env',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Shared Environment Variables'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Environment variable deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function server_delete_env(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$server = $this->resolveServer($teamId, $request->route('uuid'));
|
||||
if ($server instanceof JsonResponse) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return $this->deleteEnv(
|
||||
$teamId,
|
||||
$request->route('env_id'),
|
||||
'server',
|
||||
['server_id' => $server->id],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $tag->uuid,
|
||||
'name' => $tag->name,
|
||||
'created_at' => $tag->created_at,
|
||||
'updated_at' => $tag->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTagName(string $name): string
|
||||
{
|
||||
return strtolower(trim(strip_tags($name)));
|
||||
}
|
||||
|
||||
private function validateTagWriteRequest(Request $request, array $allowedFields = ['name']): array|JsonResponse
|
||||
{
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => 'required|string|min:2|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$name = $this->normalizeTagName((string) $request->input('name'));
|
||||
if (mb_strlen($name) < 2) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['name' => ['The tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return ['name' => $name];
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
|
||||
|
||||
return in_array($sqlState, ['23000', '23505'], true)
|
||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all tags for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'list-tags',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All tags for the current team.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
|
||||
|
||||
return response()->json($tags->map(self::serializeTag(...)));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create',
|
||||
description: 'Create a tag for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'create-tag',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', minLength: 2, maxLength: 255),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tag created.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Tag'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 409, description: 'Tag with this name already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Tag::class);
|
||||
|
||||
$validated = $this->validateTagWriteRequest($request);
|
||||
if ($validated instanceof JsonResponse) {
|
||||
return $validated;
|
||||
}
|
||||
|
||||
if (Tag::where('team_id', $teamId)->where('name', $validated['name'])->exists()) {
|
||||
return response()->json(['message' => 'Tag with this name already exists.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$tag = Tag::create([
|
||||
'name' => $validated['name'],
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'Tag with this name already exists.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.tag.created', [
|
||||
'team_id' => $teamId,
|
||||
'tag_uuid' => $tag->uuid,
|
||||
'tag_name' => $tag->name,
|
||||
]);
|
||||
|
||||
return response()->json(self::serializeTag($tag), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update',
|
||||
description: 'Update a tag name for the current team.',
|
||||
path: '/tags/{uuid}',
|
||||
operationId: 'update-tag-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Tag UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', minLength: 2, maxLength: 255),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag updated.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Tag'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Tag with this name already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validated = $this->validateTagWriteRequest($request);
|
||||
if ($validated instanceof JsonResponse) {
|
||||
return $validated;
|
||||
}
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $uuid)->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $tag);
|
||||
|
||||
if ($validated['name'] !== $tag->name
|
||||
&& Tag::where('team_id', $teamId)->where('name', $validated['name'])->where('id', '!=', $tag->id)->exists()) {
|
||||
return response()->json(['message' => 'Tag with this name already exists.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$tag->update(['name' => $validated['name']]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'Tag with this name already exists.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.tag.updated', [
|
||||
'team_id' => $teamId,
|
||||
'tag_uuid' => $tag->uuid,
|
||||
'tag_name' => $tag->name,
|
||||
'changed_fields' => ['name'],
|
||||
]);
|
||||
|
||||
return response()->json(self::serializeTag($tag->refresh()));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete a tag for the current team. Detaches the tag from all resources via cascade.',
|
||||
path: '/tags/{uuid}',
|
||||
operationId: 'delete-tag-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Tag UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag deleted.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Tag deleted.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $uuid)->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $tag);
|
||||
|
||||
$tagUuid = $tag->uuid;
|
||||
$tagName = $tag->name;
|
||||
// taggables rows cascade-delete via FK on tag_id
|
||||
$tag->delete();
|
||||
|
||||
auditLog('api.tag.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'tag_uuid' => $tagUuid,
|
||||
'tag_name' => $tagName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Tag deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$team = $this->removeSensitiveData($team);
|
||||
|
||||
return response()->json(
|
||||
@@ -168,6 +169,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
@@ -182,9 +184,9 @@ class TeamController extends Controller
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Authenticated Team',
|
||||
description: 'Get currently authenticated team.',
|
||||
path: '/teams/current',
|
||||
operationId: 'get-current-team',
|
||||
description: 'Get the team bound to the API token.',
|
||||
path: '/team',
|
||||
operationId: 'get-token-team',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
@@ -192,7 +194,7 @@ class TeamController extends Controller
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Current Team.',
|
||||
description: 'Team bound to the API token.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Team')),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
@@ -222,9 +224,9 @@ class TeamController extends Controller
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Authenticated Team Members',
|
||||
description: 'Get currently authenticated team members.',
|
||||
path: '/teams/current/members',
|
||||
operationId: 'get-current-team-members',
|
||||
description: 'Get members of the team bound to the API token.',
|
||||
path: '/team/members',
|
||||
operationId: 'get-token-team-members',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
@@ -232,7 +234,7 @@ class TeamController extends Controller
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Currently authenticated team members.',
|
||||
description: 'Members of the team bound to the API token.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'VolumeBackupScheduleRequest',
|
||||
required: ['frequency'],
|
||||
properties: [
|
||||
new OA\Property(property: 'frequency', type: 'string', maxLength: 255, example: '0 2 * * *'),
|
||||
new OA\Property(property: 'enabled', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
|
||||
],
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'VolumeBackupScheduleResponse',
|
||||
required: ['uuid', 'message', 'storage_uuid', 'storage_type', 'frequency', 'enabled', 'save_s3', 'disable_local_backup', 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', 'retention_amount_s3', 'retention_days_s3', 'retention_max_storage_s3', 'timeout'],
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'storage_uuid', type: 'string'),
|
||||
new OA\Property(property: 'storage_type', type: 'string', enum: ['persistent', 'directory']),
|
||||
new OA\Property(property: 'frequency', type: 'string'),
|
||||
new OA\Property(property: 'enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'save_s3', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean'),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean'),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'),
|
||||
new OA\Property(property: 'retention_amount_s3', type: 'integer'),
|
||||
new OA\Property(property: 'retention_days_s3', type: 'integer'),
|
||||
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float'),
|
||||
new OA\Property(property: 'timeout', type: 'integer'),
|
||||
],
|
||||
type: 'object',
|
||||
)]
|
||||
class VolumeBackupsController extends Controller
|
||||
{
|
||||
#[OA\Put(
|
||||
summary: 'Set application storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-application-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set database storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-database-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set service storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-service-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function upsert(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$invalidRequest = validateIncomingRequest($request);
|
||||
if ($invalidRequest instanceof JsonResponse) {
|
||||
return $invalidRequest;
|
||||
}
|
||||
|
||||
$resourceType = $request->route('resource_type');
|
||||
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json([
|
||||
'message' => match ($resourceType) {
|
||||
'application' => 'Application not found.',
|
||||
'database' => 'Database not found.',
|
||||
'service' => 'Service not found.',
|
||||
default => 'Resource not found.',
|
||||
},
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$storage = $this->findStorage($resource, $request->route('storage_uuid'));
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
}
|
||||
|
||||
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
|
||||
*/
|
||||
private function validateUpsertRequest(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
): array {
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'frequency' => 'required|string|max:255',
|
||||
'enabled' => 'boolean',
|
||||
'save_s3' => 'boolean',
|
||||
'disable_local_backup' => 'boolean',
|
||||
'stop_during_backup' => 'boolean',
|
||||
's3_storage_uuid' => 'nullable|string',
|
||||
'retention_amount_locally' => 'integer|min:0|max:10000',
|
||||
'retention_days_locally' => 'integer|min:0|max:2147483647',
|
||||
'retention_max_storage_locally' => 'numeric|min:0|max:9999999999',
|
||||
'retention_amount_s3' => 'integer|min:0|max:10000',
|
||||
'retention_days_s3' => 'integer|min:0|max:2147483647',
|
||||
'retention_max_storage_s3' => 'numeric|min:0|max:9999999999',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
$errors = $validator->errors();
|
||||
$allowedFields = [
|
||||
'frequency',
|
||||
'enabled',
|
||||
'save_s3',
|
||||
'disable_local_backup',
|
||||
'stop_during_backup',
|
||||
's3_storage_uuid',
|
||||
'retention_amount_locally',
|
||||
'retention_days_locally',
|
||||
'retention_max_storage_locally',
|
||||
'retention_amount_s3',
|
||||
'retention_days_s3',
|
||||
'retention_max_storage_s3',
|
||||
'timeout',
|
||||
];
|
||||
|
||||
foreach (array_diff(array_keys($request->all()), $allowedFields) as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
if (! $errors->has('frequency') && ! validate_cron_expression($request->string('frequency')->toString())) {
|
||||
$errors->add('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
}
|
||||
|
||||
$saveToS3 = $request->boolean('save_s3');
|
||||
if ($request->boolean('disable_local_backup') && ! $saveToS3) {
|
||||
$errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.');
|
||||
}
|
||||
|
||||
$s3Storage = null;
|
||||
if ($saveToS3) {
|
||||
$s3Storage = S3Storage::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('is_usable', true)
|
||||
->where('uuid', $request->input('s3_storage_uuid'))
|
||||
->first();
|
||||
|
||||
if (! $s3Storage) {
|
||||
$errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($storage instanceof LocalFileVolume && (! $storage->is_directory || $storage->is_host_file)) {
|
||||
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
's3Storage' => $s3Storage,
|
||||
'saveToS3' => $saveToS3,
|
||||
];
|
||||
}
|
||||
|
||||
private function persistSchedule(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
?S3Storage $s3Storage,
|
||||
bool $saveToS3,
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
): JsonResponse {
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], [
|
||||
'team_id' => $teamId,
|
||||
'frequency' => $request->string('frequency')->toString(),
|
||||
'enabled' => $request->boolean('enabled', true),
|
||||
'save_s3' => $saveToS3,
|
||||
'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'),
|
||||
'stop_during_backup' => $request->boolean('stop_during_backup'),
|
||||
's3_storage_id' => $s3Storage?->id,
|
||||
'retention_amount_locally' => $request->integer('retention_amount_locally', 7),
|
||||
'retention_days_locally' => $request->integer('retention_days_locally'),
|
||||
'retention_max_storage_locally' => $request->float('retention_max_storage_locally'),
|
||||
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
|
||||
'retention_days_s3' => $request->integer('retention_days_s3'),
|
||||
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
|
||||
'timeout' => $request->integer('timeout', 3600),
|
||||
]);
|
||||
$created = $backup->wasRecentlyCreated;
|
||||
|
||||
auditLog('api.volume_backup.schedule_set', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete application storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for an application storage.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-application-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete database storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a database storage.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-database-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete service storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a service storage.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-service-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resourceType = $request->route('resource_type');
|
||||
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => 'Resource not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$storage = $this->findStorage($resource, $request->route('storage_uuid'));
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
}
|
||||
|
||||
$backup = $storage->scheduledBackups()->first();
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Storage backup schedule not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
DeleteScheduledVolumeBackup::run($backup);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json(['message' => $exception->getMessage()], 409);
|
||||
}
|
||||
|
||||
auditLog('api.volume_backup.schedule_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage backup schedule and archives deleted.']);
|
||||
}
|
||||
|
||||
private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model
|
||||
{
|
||||
return match ($resourceType) {
|
||||
'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(),
|
||||
'database' => queryDatabaseByUuidWithinTeam($uuid, $teamId),
|
||||
'service' => Service::query()->whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function findStorage(Model $resource, string $storageUuid): LocalPersistentVolume|LocalFileVolume|null
|
||||
{
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications->concat($resource->databases) as $serviceResource) {
|
||||
$storage = $this->findStorage($serviceResource, $storageUuid);
|
||||
if ($storage) {
|
||||
return $storage;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$storage = $resource->persistentStorages()->where('uuid', $storageUuid)->first();
|
||||
|
||||
return $storage ?? $resource->fileStorages()->where('uuid', $storageUuid)->first();
|
||||
}
|
||||
|
||||
private function responseData(
|
||||
ScheduledVolumeBackup $backup,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
?S3Storage $s3Storage,
|
||||
bool $created,
|
||||
): array {
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'message' => $created ? 'Storage backup schedule created.' : 'Storage backup schedule updated.',
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'storage_type' => $storage instanceof LocalFileVolume ? 'directory' : 'persistent',
|
||||
'frequency' => $backup->frequency,
|
||||
'enabled' => $backup->enabled,
|
||||
'save_s3' => $backup->save_s3,
|
||||
'disable_local_backup' => $backup->disable_local_backup,
|
||||
'stop_during_backup' => $backup->stop_during_backup,
|
||||
's3_storage_uuid' => $s3Storage?->uuid,
|
||||
'retention_amount_locally' => $backup->retention_amount_locally,
|
||||
'retention_days_locally' => $backup->retention_days_locally,
|
||||
'retention_max_storage_locally' => $backup->retention_max_storage_locally,
|
||||
'retention_amount_s3' => $backup->retention_amount_s3,
|
||||
'retention_days_s3' => $backup->retention_days_s3,
|
||||
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
|
||||
'timeout' => $backup->timeout,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Run application storage backup',
|
||||
description: 'Queue an immediate volume backup for an application storage that has a schedule.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-application-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Run database storage backup',
|
||||
description: 'Queue an immediate volume backup for a database storage that has a schedule.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-database-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Run service storage backup',
|
||||
description: 'Queue an immediate volume backup for a service storage that has a schedule.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups/run',
|
||||
operationId: 'run-service-storage-backup',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Storage backup queued.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function run(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resourceType = $request->route('resource_type');
|
||||
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json([
|
||||
'message' => match ($resourceType) {
|
||||
'application' => 'Application not found.',
|
||||
'database' => 'Database not found.',
|
||||
'service' => 'Service not found.',
|
||||
default => 'Resource not found.',
|
||||
},
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$storage = $this->findStorage($resource, $request->route('storage_uuid'));
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
}
|
||||
|
||||
$backup = $storage->scheduledBackups()->first();
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Storage backup schedule not found.'], 404);
|
||||
}
|
||||
|
||||
VolumeBackupJob::dispatch($backup);
|
||||
|
||||
auditLog('api.volume_backup.run', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Storage backup queued.',
|
||||
'uuid' => $backup->uuid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class VultrController extends Controller
|
||||
{
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
}
|
||||
|
||||
private function getVultrToken(Request $request): CloudProviderToken|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($this->getCloudProviderTokenUuid($request))
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Vultr Regions',
|
||||
description: 'Get all available Vultr regions.',
|
||||
path: '/vultr/regions',
|
||||
operationId: 'get-vultr-regions',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Vultr'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Vultr regions.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function regions(Request $request): JsonResponse
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getRegions());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Vultr Plans',
|
||||
description: 'Get all available Vultr plans.',
|
||||
path: '/vultr/plans',
|
||||
operationId: 'get-vultr-plans',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Vultr'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Vultr plans.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function plans(Request $request): JsonResponse
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getPlans());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Vultr Operating Systems',
|
||||
description: 'Get all available Vultr operating systems.',
|
||||
path: '/vultr/os',
|
||||
operationId: 'get-vultr-operating-systems',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Vultr'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Vultr operating systems.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function operatingSystems(Request $request): JsonResponse
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getOperatingSystems());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Vultr SSH Keys',
|
||||
description: 'Get all Vultr SSH keys available to the selected token.',
|
||||
path: '/vultr/ssh-keys',
|
||||
operationId: 'get-vultr-ssh-keys',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Vultr'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'List of Vultr SSH keys.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request): JsonResponse
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getSshKeys());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Vultr Server',
|
||||
description: 'Create a Vultr instance and link it as a Coolify server.',
|
||||
path: '/servers/vultr',
|
||||
operationId: 'create-vultr-server',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Vultr'],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Vultr server created.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request): JsonResponse
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'region',
|
||||
'plan',
|
||||
'os_id',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_ipv6',
|
||||
'disable_public_ipv4',
|
||||
'vultr_ssh_key_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'region' => 'required|string',
|
||||
'plan' => 'required|string',
|
||||
'os_id' => 'required|integer',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'disable_public_ipv4' => 'nullable|boolean',
|
||||
'vultr_ssh_key_ids' => 'nullable|array',
|
||||
'vultr_ssh_key_ids.*' => 'string',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
if (! $request->name) {
|
||||
$request->offsetSet('name', generate_random_name());
|
||||
}
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->disable_public_ipv4)) {
|
||||
$request->offsetSet('disable_public_ipv4', false);
|
||||
}
|
||||
if (is_null($request->vultr_ssh_key_ids)) {
|
||||
$request->offsetSet('vultr_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
if ($request->disable_public_ipv4 && ! $request->enable_ipv6) {
|
||||
return $this->networkConfigurationErrorResponse();
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($this->getCloudProviderTokenUuid($request))
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$vultrService = null;
|
||||
$vultrInstanceId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$vultrService = new VultrService($token->token);
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
|
||||
|
||||
if ($existingKey) {
|
||||
$sshKeyId = $existingKey['id'];
|
||||
} else {
|
||||
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
|
||||
$sshKeyId = $uploadedKey['id'];
|
||||
}
|
||||
|
||||
$normalizedServerName = strtolower(trim($request->name));
|
||||
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids)));
|
||||
|
||||
$params = [
|
||||
'region' => $request->region,
|
||||
'plan' => $request->plan,
|
||||
'os_id' => $request->os_id,
|
||||
'label' => $normalizedServerName,
|
||||
'hostname' => $normalizedServerName,
|
||||
'sshkey_id' => $sshKeys,
|
||||
'enable_ipv6' => $request->enable_ipv6,
|
||||
'disable_public_ipv4' => $request->disable_public_ipv4,
|
||||
];
|
||||
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
}
|
||||
|
||||
$vultrInstance = $vultrService->createInstance($params);
|
||||
$vultrInstanceId = (string) $vultrInstance['id'];
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server {
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.vultr_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
logger()->error('Failed to create Vultr server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
|
||||
{
|
||||
if (! $vultrService || ! $vultrInstanceId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
|
||||
{
|
||||
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
|
||||
|
||||
foreach ($sshKeys as $sshKey) {
|
||||
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
|
||||
return $sshKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePublicKey(string $publicKey): string
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($publicKey));
|
||||
|
||||
return implode(' ', array_slice($parts ?: [], 0, 2));
|
||||
}
|
||||
|
||||
private function networkConfigurationErrorResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ use App\Events\TestEvent;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@@ -39,9 +40,29 @@ class Controller extends BaseController
|
||||
return view('auth.verify-email');
|
||||
}
|
||||
|
||||
public function email_verify(EmailVerificationRequest $request)
|
||||
public function email_verify(Request $request)
|
||||
{
|
||||
$request->fulfill();
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
@@ -77,27 +98,50 @@ class Controller extends BaseController
|
||||
public function link()
|
||||
{
|
||||
$token = request()->get('token');
|
||||
if ($token) {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
$email = str($decrypted)->before('@@@');
|
||||
$password = str($decrypted)->after('@@@');
|
||||
if (is_string($token) && $token !== '') {
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (! str_contains($decrypted, '@@@')) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} else {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$invitation = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||
->first();
|
||||
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$invitation = TeamInvitation::whereEmail($email);
|
||||
if ($invitation->exists()) {
|
||||
$team = $invitation->first()->team;
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
|
||||
$invitation->delete();
|
||||
} else {
|
||||
$team = $user->teams()->first();
|
||||
}
|
||||
if (is_null(data_get($user, 'email_verified_at'))) {
|
||||
$user->email_verified_at = now();
|
||||
$user->save();
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$invitation->delete();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
@@ -108,6 +152,19 @@ class Controller extends BaseController
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
|
||||
{
|
||||
$query = parse_url($invitation->link, PHP_URL_QUERY);
|
||||
if (! is_string($query)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
parse_str($query, $parameters);
|
||||
$storedToken = $parameters['token'] ?? null;
|
||||
|
||||
return is_string($storedToken) && hash_equals($storedToken, $token);
|
||||
}
|
||||
|
||||
public function showInvitation()
|
||||
{
|
||||
$invitationUuid = request()->route('uuid');
|
||||
|
||||
@@ -19,7 +19,12 @@ class OauthController extends Controller
|
||||
{
|
||||
try {
|
||||
$oauthUser = get_socialite_provider($provider)->user();
|
||||
$user = User::whereEmail($oauthUser->email)->first();
|
||||
$email = trim((string) $oauthUser->email);
|
||||
if ($email === '') {
|
||||
abort(403, 'OAuth provider did not return an email address');
|
||||
}
|
||||
$email = strtolower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
@@ -28,7 +33,7 @@ class OauthController extends Controller
|
||||
|
||||
$user = User::create([
|
||||
'name' => $oauthUser->name,
|
||||
'email' => $oauthUser->email,
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
Auth::login($user);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ProfileAvatarController extends Controller
|
||||
{
|
||||
public function __invoke(AvatarStorageService $avatarStorage): Response
|
||||
{
|
||||
$contents = $avatarStorage->contents(auth()->user());
|
||||
abort_if($contents === null, 404);
|
||||
|
||||
return response($contents, 200, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'private, max-age=300',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectIconStorageService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ProjectIconController extends Controller
|
||||
{
|
||||
public function __invoke(string $project_uuid, ProjectIconStorageService $iconStorage): Response
|
||||
{
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$contents = $iconStorage->projectContents($project);
|
||||
|
||||
abort_if($contents === null, 404);
|
||||
|
||||
return response($contents)->header('Content-Type', 'image/jpeg');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@@ -11,6 +13,12 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
|
||||
|
||||
class UploadController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
|
||||
|
||||
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
|
||||
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$databaseIdentifier = request()->route('databaseUuid');
|
||||
@@ -18,6 +26,24 @@ class UploadController extends BaseController
|
||||
if (is_null($resource)) {
|
||||
return response()->json(['error' => 'You do not have permission for this database'], 500);
|
||||
}
|
||||
|
||||
$this->authorize('uploadBackup', $resource);
|
||||
|
||||
$chunk = $request->file('file');
|
||||
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
|
||||
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
|
||||
return response()->json([
|
||||
'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
|
||||
if ($declaredTotalSize > self::MAX_BYTES) {
|
||||
return response()->json([
|
||||
'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||
|
||||
if ($receiver->isUploaded() === false) {
|
||||
@@ -40,29 +66,17 @@ class UploadController extends BaseController
|
||||
'status' => true,
|
||||
]);
|
||||
}
|
||||
// protected function saveFileToS3($file)
|
||||
// {
|
||||
// $fileName = $this->createFilename($file);
|
||||
|
||||
// $disk = Storage::disk('s3');
|
||||
// // It's better to use streaming Streaming (laravel 5.4+)
|
||||
// $disk->putFileAs('photos', $file, $fileName);
|
||||
|
||||
// // for older laravel
|
||||
// // $disk->put($fileName, file_get_contents($file), 'public');
|
||||
// $mime = str_replace('/', '-', $file->getMimeType());
|
||||
|
||||
// // We need to delete the file when uploaded to s3
|
||||
// unlink($file->getPathname());
|
||||
|
||||
// return response()->json([
|
||||
// 'path' => $disk->url($fileName),
|
||||
// 'name' => $fileName,
|
||||
// 'mime_type' => $mime
|
||||
// ]);
|
||||
// }
|
||||
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
|
||||
{
|
||||
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
|
||||
@unlink($file->getPathname());
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Uploaded file failed validation.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$mime = str_replace('/', '-', $file->getMimeType());
|
||||
$filePath = "upload/{$resourceIdentifier}";
|
||||
$finalPath = storage_path('app/'.$filePath);
|
||||
@@ -73,13 +87,13 @@ class UploadController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createFilename(UploadedFile $file)
|
||||
private static function hasAllowedExtension(string $name): bool
|
||||
{
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
|
||||
return DatabaseBackupFileValidator::hasAllowedExtension($name);
|
||||
}
|
||||
|
||||
$filename .= '_'.md5(time()).'.'.$extension;
|
||||
|
||||
return $filename;
|
||||
private static function formatMaxSize(): string
|
||||
{
|
||||
return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user