Merge branch 'next' into feat/servicedatabase-restore
This commit is contained in:
commit
360a82ec5d
290 changed files with 18736 additions and 3492 deletions
37
.github/workflows/claude.yml
vendored
Normal file
37
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
claude_args: '--model opus'
|
||||||
6443
CHANGELOG.md
6443
CHANGELOG.md
File diff suppressed because it is too large
Load diff
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -10,6 +10,42 @@ ## Project Overview
|
||||||
|
|
||||||
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
|
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
|
||||||
|
|
||||||
|
## Git Worktree Shared Dependencies
|
||||||
|
|
||||||
|
This repository uses git worktrees for parallel development with **automatic shared dependency setup** via Conductor.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
The `conductor.json` setup script (`scripts/conductor-setup.sh`) automatically:
|
||||||
|
1. Creates symlinks from worktree's `node_modules` and `vendor` to the main repository's directories
|
||||||
|
2. All worktrees share the same dependencies from the main repository
|
||||||
|
3. This happens automatically when Conductor creates a new worktree
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
- **Save disk space**: Only one copy of dependencies across all worktrees
|
||||||
|
- **Faster setup**: No need to run `npm install` or `composer install` for each worktree
|
||||||
|
- **Consistent versions**: All worktrees use the same dependency versions
|
||||||
|
- **Auto-configured**: Handled by Conductor's setup script
|
||||||
|
- **Simple**: Uses the main repo's existing directories, no extra folders
|
||||||
|
|
||||||
|
### Manual Setup (If Needed)
|
||||||
|
|
||||||
|
If you need to set up symlinks manually or for non-Conductor worktrees:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the worktree directory
|
||||||
|
rm -rf node_modules vendor
|
||||||
|
ln -sf ../../node_modules node_modules
|
||||||
|
ln -sf ../../vendor vendor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- Dependencies are shared from the main repository (`$CONDUCTOR_ROOT_PATH`)
|
||||||
|
- Run `npm install` or `composer install` from the main repo or any worktree to update all
|
||||||
|
- If different branches need different dependency versions, this won't work - remove symlinks and use separate directories
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,14 @@ public function handle(Application $application, bool $previewDeployments = fals
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset restart tracking when application is manually stopped
|
||||||
|
$application->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => [
|
||||||
'test' => "clickhouse-client --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
|
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
|
||||||
'interval' => '5s',
|
'interval' => '5s',
|
||||||
'timeout' => '5s',
|
'timeout' => '5s',
|
||||||
'retries' => 10,
|
'retries' => 10,
|
||||||
|
|
@ -152,12 +152,16 @@ private function generate_environment_variables()
|
||||||
$environment_variables->push("$env->key=$env->real_value");
|
$environment_variables->push("$env->key=$env->real_value");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_USER'))->isEmpty()) {
|
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
|
||||||
$environment_variables->push("CLICKHOUSE_ADMIN_USER={$this->database->clickhouse_admin_user}");
|
$environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_PASSWORD'))->isEmpty()) {
|
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) {
|
||||||
$environment_variables->push("CLICKHOUSE_ADMIN_PASSWORD={$this->database->clickhouse_admin_password}");
|
$environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) {
|
||||||
|
$environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}");
|
||||||
}
|
}
|
||||||
|
|
||||||
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
|
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
use App\Helpers\SslHelper;
|
use App\Helpers\SslHelper;
|
||||||
use App\Models\SslCertificate;
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneKeydb;
|
use App\Models\StandaloneKeydb;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
|
@ -270,10 +269,9 @@ private function add_custom_keydb()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$filename = 'keydb.conf';
|
$filename = 'keydb.conf';
|
||||||
Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf);
|
$content = $this->database->keydb_conf;
|
||||||
$path = Storage::path("tmp/keydb.conf_{$this->database->uuid}");
|
$content_base64 = base64_encode($content);
|
||||||
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
|
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
|
||||||
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildStartCommand(): string
|
private function buildStartCommand(): string
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
use App\Helpers\SslHelper;
|
use App\Helpers\SslHelper;
|
||||||
use App\Models\SslCertificate;
|
use App\Models\SslCertificate;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
|
@ -316,9 +315,8 @@ private function add_custom_redis()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$filename = 'redis.conf';
|
$filename = 'redis.conf';
|
||||||
Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf);
|
$content = $this->database->redis_conf;
|
||||||
$path = Storage::path("tmp/redis.conf_{$this->database->uuid}");
|
$content_base64 = base64_encode($content);
|
||||||
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
|
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
|
||||||
Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
||||||
|
|
||||||
$this->stopContainer($database, $database->uuid, 30);
|
$this->stopContainer($database, $database->uuid, 30);
|
||||||
|
|
||||||
|
// Reset restart tracking when database is manually stopped
|
||||||
|
$database->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($dockerCleanup) {
|
if ($dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, false, false);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,12 +199,26 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
||||||
$isPublic = data_get($database, 'is_public');
|
$isPublic = data_get($database, 'is_public');
|
||||||
$foundDatabases[] = $database->id;
|
$foundDatabases[] = $database->id;
|
||||||
$statusFromDb = $database->status;
|
$statusFromDb = $database->status;
|
||||||
|
|
||||||
|
// Track restart count for databases (single-container)
|
||||||
|
$restartCount = data_get($container, 'RestartCount', 0);
|
||||||
|
$previousRestartCount = $database->restart_count ?? 0;
|
||||||
|
|
||||||
if ($statusFromDb !== $containerStatus) {
|
if ($statusFromDb !== $containerStatus) {
|
||||||
$database->update(['status' => $containerStatus]);
|
$updateData = ['status' => $containerStatus];
|
||||||
} else {
|
} else {
|
||||||
$database->update(['last_online_at' => now()]);
|
$updateData = ['last_online_at' => now()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update restart tracking if restart count increased
|
||||||
|
if ($restartCount > $previousRestartCount) {
|
||||||
|
$updateData['restart_count'] = $restartCount;
|
||||||
|
$updateData['last_restart_at'] = now();
|
||||||
|
$updateData['last_restart_type'] = 'crash';
|
||||||
|
}
|
||||||
|
|
||||||
|
$database->update($updateData);
|
||||||
|
|
||||||
if ($isPublic) {
|
if ($isPublic) {
|
||||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||||
if ($this->server->isSwarm()) {
|
if ($this->server->isSwarm()) {
|
||||||
|
|
@ -365,7 +379,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
||||||
if (str($database->status)->startsWith('exited')) {
|
if (str($database->status)->startsWith('exited')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$database->update(['status' => 'exited']);
|
// Reset restart tracking when database exits completely
|
||||||
|
$database->update([
|
||||||
|
'status' => 'exited',
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
$name = data_get($database, 'name');
|
$name = data_get($database, 'name');
|
||||||
$fqdn = data_get($database, 'fqdn');
|
$fqdn = data_get($database, 'fqdn');
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,15 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
||||||
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
||||||
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
||||||
|
|
||||||
// Build image prune command that excludes application images
|
// Build image prune command that excludes application images and current Coolify infrastructure images
|
||||||
// This ensures we clean up non-Coolify images while preserving rollback images
|
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
|
||||||
$imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos);
|
// Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
|
||||||
|
// We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
|
||||||
|
$imagePruneCmd = $this->buildImagePruneCommand(
|
||||||
|
$applicationImageRepos,
|
||||||
|
$helperImageVersion,
|
||||||
|
$realtimeImageVersion
|
||||||
|
);
|
||||||
|
|
||||||
$commands = [
|
$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"',
|
||||||
|
|
@ -78,33 +84,51 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
||||||
* Since docker image prune doesn't support excluding by repository name directly,
|
* Since docker image prune doesn't support excluding by repository name directly,
|
||||||
* we use a shell script approach to delete unused images while preserving application images.
|
* we use a shell script approach to delete unused images while preserving application images.
|
||||||
*/
|
*/
|
||||||
private function buildImagePruneCommand($applicationImageRepos): string
|
private function buildImagePruneCommand(
|
||||||
{
|
$applicationImageRepos,
|
||||||
|
string $helperImageVersion,
|
||||||
|
string $realtimeImageVersion
|
||||||
|
): string {
|
||||||
// Step 1: Always prune dangling images (untagged)
|
// Step 1: Always prune dangling images (untagged)
|
||||||
$commands = ['docker image prune -f'];
|
$commands = ['docker image prune -f'];
|
||||||
|
|
||||||
if ($applicationImageRepos->isEmpty()) {
|
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
|
||||||
// No applications, add original prune command for all unused images
|
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||||
$commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"';
|
// Escape special characters for grep extended regex (ERE)
|
||||||
} else {
|
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||||
// Build grep pattern to exclude application image repositories
|
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||||
$excludePatterns = $applicationImageRepos->map(function ($repo) {
|
})->implode('|');
|
||||||
// Escape special characters for grep extended regex (ERE)
|
|
||||||
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
|
||||||
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
|
||||||
})->implode('|');
|
|
||||||
|
|
||||||
// Delete unused images that:
|
// Build grep pattern to exclude Coolify infrastructure images (current version only)
|
||||||
// - Are not application images (don't match app repos)
|
// This pattern matches the image name regardless of registry prefix:
|
||||||
// - Don't have coolify.managed=true label
|
// - ghcr.io/coollabsio/coolify-helper:1.0.12
|
||||||
// Images in use by containers will fail silently with docker rmi
|
// - docker.io/coollabsio/coolify-helper:1.0.12
|
||||||
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
// - coollabsio/coolify-helper:1.0.12
|
||||||
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
|
||||||
"grep -v -E '^({$excludePatterns})[_:].+' | ".
|
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
|
||||||
"grep -v '<none>' | ".
|
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
|
||||||
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
$infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
|
||||||
|
|
||||||
|
// Delete unused images that:
|
||||||
|
// - Are not application images (don't match app repos)
|
||||||
|
// - Are not current Coolify infrastructure images (any registry)
|
||||||
|
// - Don't have coolify.managed=true label
|
||||||
|
// Images in use by containers will fail silently with docker rmi
|
||||||
|
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||||
|
$grepCommands = "grep -v '<none>'";
|
||||||
|
|
||||||
|
// Add application repo exclusion if there are applications
|
||||||
|
if ($applicationImageRepos->isNotEmpty()) {
|
||||||
|
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add infrastructure image exclusion (matches any registry prefix)
|
||||||
|
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
|
||||||
|
|
||||||
|
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
||||||
|
$grepCommands.' | '.
|
||||||
|
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
||||||
|
|
||||||
return implode(' && ', $commands);
|
return implode(' && ', $commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,19 +148,6 @@ private function getSuseDockerInstallCommand(): string
|
||||||
')';
|
')';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getArchDockerInstallCommand(): string
|
|
||||||
{
|
|
||||||
return 'pacman -Syyy --noconfirm && '.
|
|
||||||
'pacman -S docker docker-compose --noconfirm && '.
|
|
||||||
'systemctl start docker && '.
|
|
||||||
'systemctl enable docker';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getGenericDockerInstallCommand(): string
|
|
||||||
{
|
|
||||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getArchDockerInstallCommand(): string
|
private function getArchDockerInstallCommand(): string
|
||||||
{
|
{
|
||||||
// Use -Syu to perform full system upgrade before installing Docker
|
// Use -Syu to perform full system upgrade before installing Docker
|
||||||
|
|
@ -171,4 +158,9 @@ private function getArchDockerInstallCommand(): string
|
||||||
'systemctl enable docker.service && '.
|
'systemctl enable docker.service && '.
|
||||||
'systemctl start docker.service';
|
'systemctl start docker.service';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getGenericDockerInstallCommand(): string
|
||||||
|
{
|
||||||
|
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ public function handle($manual_update = false)
|
||||||
if (! $this->server) {
|
if (! $this->server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CleanupDocker::dispatch($this->server, false, false);
|
|
||||||
|
|
||||||
// Fetch fresh version from CDN instead of using cache
|
// Fetch fresh version from CDN instead of using cache
|
||||||
try {
|
try {
|
||||||
|
|
@ -117,17 +116,12 @@ public function handle($manual_update = false)
|
||||||
|
|
||||||
private function update()
|
private function update()
|
||||||
{
|
{
|
||||||
$helperImage = config('constants.coolify.helper_image');
|
$latestHelperImageVersion = getHelperVersion();
|
||||||
$latest_version = getHelperVersion();
|
|
||||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
|
||||||
|
|
||||||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
|
||||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
|
||||||
|
|
||||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||||
|
|
||||||
remote_process([
|
remote_process([
|
||||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
|
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
||||||
], $this->server);
|
], $this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
app/Console/Commands/Cloud/SyncStripeSubscriptions.php
Normal file
81
app/Console/Commands/Cloud/SyncStripeSubscriptions.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Cloud;
|
||||||
|
|
||||||
|
use App\Jobs\SyncStripeSubscriptionsJob;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SyncStripeSubscriptions extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cloud:sync-stripe-subscriptions {--fix : Actually fix discrepancies (default is check only)}';
|
||||||
|
|
||||||
|
protected $description = 'Sync subscription status with Stripe. By default only checks, use --fix to apply changes.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! isCloud()) {
|
||||||
|
$this->error('This command can only be run on Coolify Cloud.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isStripe()) {
|
||||||
|
$this->error('Stripe is not configured.');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fix = $this->option('fix');
|
||||||
|
|
||||||
|
if ($fix) {
|
||||||
|
$this->warn('Running with --fix: discrepancies will be corrected.');
|
||||||
|
} else {
|
||||||
|
$this->info('Running in check mode (no changes will be made). Use --fix to apply corrections.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$job = new SyncStripeSubscriptionsJob($fix);
|
||||||
|
$result = $job->handle();
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
$this->error($result['error']);
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Total subscriptions checked: {$result['total_checked']}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (count($result['discrepancies']) > 0) {
|
||||||
|
$this->warn('Discrepancies found: '.count($result['discrepancies']));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
foreach ($result['discrepancies'] as $discrepancy) {
|
||||||
|
$this->line(" - Subscription ID: {$discrepancy['subscription_id']}");
|
||||||
|
$this->line(" Team ID: {$discrepancy['team_id']}");
|
||||||
|
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
|
||||||
|
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fix) {
|
||||||
|
$this->info('All discrepancies have been fixed.');
|
||||||
|
} else {
|
||||||
|
$this->comment('Run with --fix to correct these discrepancies.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->info('No discrepancies found. All subscriptions are in sync.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($result['errors']) > 0) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->error('Errors encountered: '.count($result['errors']));
|
||||||
|
foreach ($result['errors'] as $error) {
|
||||||
|
$this->line(" - Subscription {$error['subscription_id']}: {$error['error']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,15 +14,11 @@
|
||||||
use App\Jobs\ServerManagerJob;
|
use App\Jobs\ServerManagerJob;
|
||||||
use App\Jobs\UpdateCoolifyJob;
|
use App\Jobs\UpdateCoolifyJob;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\Team;
|
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
private $allServers;
|
|
||||||
|
|
||||||
private Schedule $scheduleInstance;
|
private Schedule $scheduleInstance;
|
||||||
|
|
||||||
private InstanceSettings $settings;
|
private InstanceSettings $settings;
|
||||||
|
|
@ -34,8 +30,6 @@ class Kernel extends ConsoleKernel
|
||||||
protected function schedule(Schedule $schedule): void
|
protected function schedule(Schedule $schedule): void
|
||||||
{
|
{
|
||||||
$this->scheduleInstance = $schedule;
|
$this->scheduleInstance = $schedule;
|
||||||
$this->allServers = Server::where('ip', '!=', '1.2.3.4');
|
|
||||||
|
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
||||||
|
|
||||||
|
|
@ -95,14 +89,6 @@ protected function schedule(Schedule $schedule): void
|
||||||
|
|
||||||
private function pullImages(): void
|
private function pullImages(): void
|
||||||
{
|
{
|
||||||
if (isCloud()) {
|
|
||||||
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
|
||||||
$own = Team::find(0)->servers;
|
|
||||||
$servers = $servers->merge($own);
|
|
||||||
} else {
|
|
||||||
$servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
|
||||||
}
|
|
||||||
// Sentinel update checks are now handled by ServerManagerJob
|
|
||||||
$this->scheduleInstance->job(new CheckHelperImageJob)
|
$this->scheduleInstance->job(new CheckHelperImageJob)
|
||||||
->cron($this->updateCheckFrequency)
|
->cron($this->updateCheckFrequency)
|
||||||
->timezone($this->instanceTimezone)
|
->timezone($this->instanceTimezone)
|
||||||
|
|
|
||||||
15
app/Exceptions/RateLimitException.php
Normal file
15
app/Exceptions/RateLimitException.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RateLimitException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Rate limit exceeded.',
|
||||||
|
public readonly ?int $retryAfter = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -192,6 +192,7 @@ public function applications(Request $request)
|
||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -342,6 +343,7 @@ public function create_public_application(Request $request)
|
||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request)
|
||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request)
|
||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request)
|
||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
@ -927,7 +932,7 @@ private function create_application(Request $request, $type)
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
|
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
|
|
@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
|
||||||
'is_http_basic_auth_enabled' => 'boolean',
|
'is_http_basic_auth_enabled' => 'boolean',
|
||||||
'http_basic_auth_username' => 'string|nullable',
|
'http_basic_auth_username' => 'string|nullable',
|
||||||
'http_basic_auth_password' => 'string|nullable',
|
'http_basic_auth_password' => 'string|nullable',
|
||||||
|
'autogenerate_domain' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
|
@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
$serverUuid = $request->server_uuid;
|
$serverUuid = $request->server_uuid;
|
||||||
$fqdn = $request->domains;
|
$fqdn = $request->domains;
|
||||||
|
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
|
||||||
$instantDeploy = $request->instant_deploy;
|
$instantDeploy = $request->instant_deploy;
|
||||||
$githubAppUuid = $request->github_app_uuid;
|
$githubAppUuid = $request->github_app_uuid;
|
||||||
$useBuildServer = $request->use_build_server;
|
$useBuildServer = $request->use_build_server;
|
||||||
|
|
@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
}
|
}
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if ($application->settings->is_container_label_readonly_enabled) {
|
if ($application->settings->is_container_label_readonly_enabled) {
|
||||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||||
$application->save();
|
$application->save();
|
||||||
|
|
@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-gh-app') {
|
} elseif ($type === 'private-gh-app') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-deploy-key') {
|
} elseif ($type === 'private-deploy-key') {
|
||||||
|
|
||||||
|
|
@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->environment_id = $environment->id;
|
$application->environment_id = $environment->id;
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerfile') {
|
} elseif ($type === 'dockerfile') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerimage') {
|
} elseif ($type === 'dockerimage') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockercompose') {
|
} elseif ($type === 'dockercompose') {
|
||||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
||||||
|
|
|
||||||
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
|
|
@ -0,0 +1,531 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
class CloudProviderTokensController extends Controller
|
||||||
|
{
|
||||||
|
private function removeSensitiveData($token)
|
||||||
|
{
|
||||||
|
$token->makeHidden([
|
||||||
|
'id',
|
||||||
|
'token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeApiResponse($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a provider token against the provider's API.
|
||||||
|
*
|
||||||
|
* @return array{valid: bool, error: string|null}
|
||||||
|
*/
|
||||||
|
private function validateProviderToken(string $provider, string $token): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = match ($provider) {
|
||||||
|
'hetzner' => Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
|
||||||
|
'digitalocean' => Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($response === null) {
|
||||||
|
return ['valid' => false, 'error' => 'Unsupported provider.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return ['valid' => true, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to validate cloud provider token', [
|
||||||
|
'provider' => $provider,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'List Cloud Provider Tokens',
|
||||||
|
description: 'List all cloud provider tokens for the authenticated team.',
|
||||||
|
path: '/cloud-tokens',
|
||||||
|
operationId: 'list-cloud-tokens',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Get all cloud provider tokens.',
|
||||||
|
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'],
|
||||||
|
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||||
|
'team_id' => ['type' => 'integer'],
|
||||||
|
'servers_count' => ['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();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->withCount('servers')
|
||||||
|
->get()
|
||||||
|
->map(function ($token) {
|
||||||
|
return $this->removeSensitiveData($token);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Cloud Provider Token',
|
||||||
|
description: 'Get cloud provider token by UUID.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'get-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Get cloud provider token by UUID',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'provider' => ['type' => 'string'],
|
||||||
|
'team_id' => ['type' => 'integer'],
|
||||||
|
'servers_count' => ['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();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($request->uuid)
|
||||||
|
->withCount('servers')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (is_null($token)) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->removeSensitiveData($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Create Cloud Provider Token',
|
||||||
|
description: 'Create a new cloud provider token. The token will be validated before being stored.',
|
||||||
|
path: '/cloud-tokens',
|
||||||
|
operationId: 'create-cloud-token',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Cloud provider token details',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
required: ['provider', 'token', 'name'],
|
||||||
|
properties: [
|
||||||
|
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], '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.'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 201,
|
||||||
|
description: 'Cloud provider token 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 token.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
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 = ['provider', 'token', 'name'];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use request body only (excludes any route parameters)
|
||||||
|
$body = $request->json()->all();
|
||||||
|
|
||||||
|
$validator = customApiValidator($body, [
|
||||||
|
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||||
|
'token' => 'required|string',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token with the provider's API
|
||||||
|
$validation = $this->validateProviderToken($body['provider'], $body['token']);
|
||||||
|
|
||||||
|
if (! $validation['valid']) {
|
||||||
|
return response()->json(['message' => $validation['error']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudProviderToken = CloudProviderToken::create([
|
||||||
|
'team_id' => $teamId,
|
||||||
|
'provider' => $body['provider'],
|
||||||
|
'token' => $body['token'],
|
||||||
|
'name' => $body['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $cloudProviderToken->uuid,
|
||||||
|
])->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Patch(
|
||||||
|
summary: 'Update Cloud Provider Token',
|
||||||
|
description: 'Update cloud provider token name.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'update-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Cloud provider token updated.',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Cloud provider token 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'];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use request body only (excludes route parameters like uuid)
|
||||||
|
$body = $request->json()->all();
|
||||||
|
|
||||||
|
$validator = customApiValidator($body, [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use route parameter for UUID lookup
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $token->uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Delete(
|
||||||
|
summary: 'Delete Cloud Provider Token',
|
||||||
|
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'delete-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the cloud provider token.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Cloud provider token deleted.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
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 destroy(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->uuid) {
|
||||||
|
return response()->json(['message' => 'UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->hasServers()) {
|
||||||
|
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Validate Cloud Provider Token',
|
||||||
|
description: 'Validate a cloud provider token against the provider API.',
|
||||||
|
path: '/cloud-tokens/{uuid}/validate',
|
||||||
|
operationId: 'validate-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Token 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' => 'Token is valid.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function validateToken(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
|
||||||
|
if (! $cloudToken) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'valid' => $validation['valid'],
|
||||||
|
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -388,7 +388,11 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
$result = $this->deploy_resource($resource, $force, $pr);
|
||||||
|
if (isset($result['status']) && $result['status'] === 429) {
|
||||||
|
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
|
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||||
if ($deployment_uuid) {
|
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->toString()]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($applications as $resource) {
|
foreach ($applications as $resource) {
|
||||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
|
$result = $this->deploy_resource($resource, $force);
|
||||||
|
if (isset($result['status']) && $result['status'] === 429) {
|
||||||
|
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
|
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||||
if ($deployment_uuid) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||||
}
|
}
|
||||||
|
|
@ -474,8 +482,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: $force,
|
force_rebuild: $force,
|
||||||
pull_request_id: $pr,
|
pull_request_id: $pr,
|
||||||
|
is_api: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$message = $result['message'];
|
$message = $result['message'];
|
||||||
} else {
|
} else {
|
||||||
$message = "Application {$resource->name} deployment queued.";
|
$message = "Application {$resource->name} deployment queued.";
|
||||||
|
|
|
||||||
738
app/Http/Controllers/Api/HetznerController.php
Normal file
738
app/Http/Controllers/Api/HetznerController.php
Normal file
|
|
@ -0,0 +1,738 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
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\HetznerService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
class HetznerController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get cloud provider token UUID from request.
|
||||||
|
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
|
||||||
|
*/
|
||||||
|
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||||
|
{
|
||||||
|
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Locations',
|
||||||
|
description: 'Get all available Hetzner datacenter locations.',
|
||||||
|
path: '/hetzner/locations',
|
||||||
|
operationId: 'get-hetzner-locations',
|
||||||
|
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 locations.',
|
||||||
|
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'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'country' => ['type' => 'string'],
|
||||||
|
'city' => ['type' => 'string'],
|
||||||
|
'latitude' => ['type' => 'number'],
|
||||||
|
'longitude' => ['type' => 'number'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function locations(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$locations = $hetznerService->getLocations();
|
||||||
|
|
||||||
|
return response()->json($locations);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Server Types',
|
||||||
|
description: 'Get all available Hetzner server types (instance sizes).',
|
||||||
|
path: '/hetzner/server-types',
|
||||||
|
operationId: 'get-hetzner-server-types',
|
||||||
|
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 server types.',
|
||||||
|
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'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'cores' => ['type' => 'integer'],
|
||||||
|
'memory' => ['type' => 'number'],
|
||||||
|
'disk' => ['type' => 'integer'],
|
||||||
|
'prices' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
|
||||||
|
'price_hourly' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'net' => ['type' => 'string'],
|
||||||
|
'gross' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'price_monthly' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'net' => ['type' => 'string'],
|
||||||
|
'gross' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function serverTypes(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$serverTypes = $hetznerService->getServerTypes();
|
||||||
|
|
||||||
|
return response()->json($serverTypes);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Images',
|
||||||
|
description: 'Get all available Hetzner system images (operating systems).',
|
||||||
|
path: '/hetzner/images',
|
||||||
|
operationId: 'get-hetzner-images',
|
||||||
|
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 images.',
|
||||||
|
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'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'type' => ['type' => 'string'],
|
||||||
|
'os_flavor' => ['type' => 'string'],
|
||||||
|
'os_version' => ['type' => 'string'],
|
||||||
|
'architecture' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function images(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$images = $hetznerService->getImages();
|
||||||
|
|
||||||
|
// Filter out deprecated images (same as UI)
|
||||||
|
$filtered = array_filter($images, function ($image) {
|
||||||
|
if (isset($image['type']) && $image['type'] !== 'system') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($image['deprecated']) && $image['deprecated'] === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json(array_values($filtered));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner SSH Keys',
|
||||||
|
description: 'Get all SSH keys stored in the Hetzner account.',
|
||||||
|
path: '/hetzner/ssh-keys',
|
||||||
|
operationId: 'get-hetzner-ssh-keys',
|
||||||
|
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 SSH keys.',
|
||||||
|
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'],
|
||||||
|
'fingerprint' => ['type' => 'string'],
|
||||||
|
'public_key' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function sshKeys(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$sshKeys = $hetznerService->getSshKeys();
|
||||||
|
|
||||||
|
return response()->json($sshKeys);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Create Hetzner Server',
|
||||||
|
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||||
|
path: '/servers/hetzner',
|
||||||
|
operationId: 'create-hetzner-server',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Hetzner server creation parameters',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
required: ['location', 'server_type', 'image', 'private_key_uuid'],
|
||||||
|
properties: [
|
||||||
|
'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
|
||||||
|
'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
|
||||||
|
'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
|
||||||
|
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
|
||||||
|
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
|
||||||
|
'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
|
||||||
|
'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)'],
|
||||||
|
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||||
|
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||||
|
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 201,
|
||||||
|
description: 'Hetzner server 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 server.'],
|
||||||
|
'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
|
||||||
|
'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 429,
|
||||||
|
ref: '#/components/responses/429',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function createServer(Request $request)
|
||||||
|
{
|
||||||
|
$allowedFields = [
|
||||||
|
'cloud_provider_token_uuid',
|
||||||
|
'cloud_provider_token_id',
|
||||||
|
'location',
|
||||||
|
'server_type',
|
||||||
|
'image',
|
||||||
|
'name',
|
||||||
|
'private_key_uuid',
|
||||||
|
'enable_ipv4',
|
||||||
|
'enable_ipv6',
|
||||||
|
'hetzner_ssh_key_ids',
|
||||||
|
'cloud_init_script',
|
||||||
|
'instant_validate',
|
||||||
|
];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\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',
|
||||||
|
'location' => 'required|string',
|
||||||
|
'server_type' => 'required|string',
|
||||||
|
'image' => 'required|integer',
|
||||||
|
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||||
|
'private_key_uuid' => 'required|string',
|
||||||
|
'enable_ipv4' => 'nullable|boolean',
|
||||||
|
'enable_ipv6' => 'nullable|boolean',
|
||||||
|
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||||
|
'hetzner_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();
|
||||||
|
if (! empty($extraFields)) {
|
||||||
|
foreach ($extraFields as $field) {
|
||||||
|
$errors->add($field, 'This field is not allowed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $errors,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server limit
|
||||||
|
if (Team::serverLimitReached()) {
|
||||||
|
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if (! $request->name) {
|
||||||
|
$request->offsetSet('name', generate_random_name());
|
||||||
|
}
|
||||||
|
if (is_null($request->enable_ipv4)) {
|
||||||
|
$request->offsetSet('enable_ipv4', true);
|
||||||
|
}
|
||||||
|
if (is_null($request->enable_ipv6)) {
|
||||||
|
$request->offsetSet('enable_ipv6', true);
|
||||||
|
}
|
||||||
|
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||||
|
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||||
|
}
|
||||||
|
if (is_null($request->instant_validate)) {
|
||||||
|
$request->offsetSet('instant_validate', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cloud provider token
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate private key
|
||||||
|
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||||
|
if (! $privateKey) {
|
||||||
|
return response()->json(['message' => 'Private key not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
|
||||||
|
// Get public key and MD5 fingerprint
|
||||||
|
$publicKey = $privateKey->getPublicKey();
|
||||||
|
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||||
|
|
||||||
|
// Check if SSH key already exists on Hetzner
|
||||||
|
$existingSshKeys = $hetznerService->getSshKeys();
|
||||||
|
$existingKey = null;
|
||||||
|
|
||||||
|
foreach ($existingSshKeys as $key) {
|
||||||
|
if ($key['fingerprint'] === $md5Fingerprint) {
|
||||||
|
$existingKey = $key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload SSH key if it doesn't exist
|
||||||
|
if ($existingKey) {
|
||||||
|
$sshKeyId = $existingKey['id'];
|
||||||
|
} else {
|
||||||
|
$sshKeyName = $privateKey->name;
|
||||||
|
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
|
||||||
|
$sshKeyId = $uploadedKey['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize server name to lowercase for RFC 1123 compliance
|
||||||
|
$normalizedServerName = strtolower(trim($request->name));
|
||||||
|
|
||||||
|
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||||
|
$sshKeys = array_merge(
|
||||||
|
[$sshKeyId],
|
||||||
|
$request->hetzner_ssh_key_ids
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
$sshKeys = array_unique($sshKeys);
|
||||||
|
$sshKeys = array_values($sshKeys);
|
||||||
|
|
||||||
|
// Prepare server creation parameters
|
||||||
|
$params = [
|
||||||
|
'name' => $normalizedServerName,
|
||||||
|
'server_type' => $request->server_type,
|
||||||
|
'image' => $request->image,
|
||||||
|
'location' => $request->location,
|
||||||
|
'start_after_create' => true,
|
||||||
|
'ssh_keys' => $sshKeys,
|
||||||
|
'public_net' => [
|
||||||
|
'enable_ipv4' => $request->enable_ipv4,
|
||||||
|
'enable_ipv6' => $request->enable_ipv6,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add cloud-init script if provided
|
||||||
|
if (! empty($request->cloud_init_script)) {
|
||||||
|
$params['user_data'] = $request->cloud_init_script;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server on Hetzner
|
||||||
|
$hetznerServer = $hetznerService->createServer($params);
|
||||||
|
|
||||||
|
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||||
|
$ipAddress = null;
|
||||||
|
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||||
|
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||||
|
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||||
|
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $ipAddress) {
|
||||||
|
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server in Coolify database
|
||||||
|
$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,
|
||||||
|
'hetzner_server_id' => $hetznerServer['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server->proxy->set('status', 'exited');
|
||||||
|
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||||
|
$server->save();
|
||||||
|
|
||||||
|
// Validate server if requested
|
||||||
|
if ($request->instant_validate) {
|
||||||
|
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $server->uuid,
|
||||||
|
'hetzner_server_id' => $hetznerServer['id'],
|
||||||
|
'ip' => $ipAddress,
|
||||||
|
])->setStatusCode(201);
|
||||||
|
} catch (RateLimitException $e) {
|
||||||
|
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||||
|
if ($e->retryAfter !== null) {
|
||||||
|
$response->header('Retry-After', $e->retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,22 @@
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)),
|
)),
|
||||||
|
new OA\Response(
|
||||||
|
response: 429,
|
||||||
|
description: 'Rate limit exceeded.',
|
||||||
|
headers: [
|
||||||
|
new OA\Header(
|
||||||
|
header: 'Retry-After',
|
||||||
|
description: 'Number of seconds to wait before retrying.',
|
||||||
|
schema: new OA\Schema(type: 'integer', example: 60)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
|
||||||
|
]
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
class OpenApi
|
class OpenApi
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,10 @@ public function current_team(Request $request)
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
$team = auth()->user()->currentTeam();
|
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||||
|
if (is_null($team)) {
|
||||||
|
return response()->json(['message' => 'Team not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->removeSensitiveData($team),
|
$this->removeSensitiveData($team),
|
||||||
|
|
@ -263,7 +266,10 @@ public function current_team_members(Request $request)
|
||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
$team = auth()->user()->currentTeam();
|
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||||
|
if (is_null($team)) {
|
||||||
|
return response()->json(['message' => 'Team not found.'], 404);
|
||||||
|
}
|
||||||
$team->members->makeHidden([
|
$team->members->makeHidden([
|
||||||
'pivot',
|
'pivot',
|
||||||
'email_change_code',
|
'email_change_code',
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ public function manual(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true
|
is_webhook: true
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -144,7 +146,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'bitbucket'
|
git_type: 'bitbucket'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,9 @@ public function manual(Request $request)
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -169,7 +171,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitea'
|
git_type: 'gitea'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,9 @@ public function manual(Request $request)
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -197,7 +199,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -305,7 +309,9 @@ public function normal(Request $request)
|
||||||
if (! $id || ! $branch) {
|
if (! $id || ! $branch) {
|
||||||
return response('Nothing to do. No id or branch found.');
|
return response('Nothing to do. No id or branch found.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
|
$applications = Application::where('repository_project_id', $id)
|
||||||
|
->where('source_id', $github_app->id)
|
||||||
|
->whereRelation('source', 'is_public', false);
|
||||||
if ($x_github_event === 'push') {
|
if ($x_github_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $applications->where('git_branch', $branch)->get();
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
|
|
@ -347,12 +353,15 @@ public function normal(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
'application_uuid' => $application->uuid,
|
'application_uuid' => $application->uuid,
|
||||||
'application_name' => $application->name,
|
'application_name' => $application->name,
|
||||||
'deployment_uuid' => $result['deployment_uuid'],
|
'deployment_uuid' => $result['deployment_uuid'] ?? null,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
|
|
@ -411,7 +420,9 @@ public function normal(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ public function manual(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
|
|
@ -202,7 +204,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitlab'
|
git_type: 'gitlab'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,21 @@ public function handle(Request $request, Closure $next): Response
|
||||||
}
|
}
|
||||||
if (auth()?->user()?->currentTeam()) {
|
if (auth()?->user()?->currentTeam()) {
|
||||||
refreshSession(auth()->user()->currentTeam());
|
refreshSession(auth()->user()->currentTeam());
|
||||||
|
} elseif (auth()?->user()?->teams?->count() > 0) {
|
||||||
|
// User's session team is invalid (e.g., removed from team), switch to first available team
|
||||||
|
refreshSession(auth()->user()->teams->first());
|
||||||
}
|
}
|
||||||
if (! auth()->user() || ! isCloud() || isInstanceAdmin()) {
|
if (! auth()->user() || ! isCloud()) {
|
||||||
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {
|
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {
|
||||||
return redirect()->route('onboarding');
|
return redirect()->route('onboarding');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
// Instance admins can access settings and admin routes regardless of subscription
|
||||||
|
if (isInstanceAdmin() && ($request->routeIs('settings.*') || $request->path() === 'admin')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
if (! auth()->user()->hasVerifiedEmail()) {
|
if (! auth()->user()->hasVerifiedEmail()) {
|
||||||
if ($request->path() === 'verify' || in_array($request->path(), allowedPathsForInvalidAccounts()) || $request->routeIs('verify.verify')) {
|
if ($request->path() === 'verify' || in_array($request->path(), allowedPathsForInvalidAccounts()) || $request->routeIs('verify.verify')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,7 @@ public function handle(): void
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Log but don't fail - container cleanup errors are expected when container is already gone
|
// Log but don't fail - container cleanup errors are expected when container is already gone
|
||||||
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
|
@ -486,15 +486,38 @@ private function decide_what_to_do()
|
||||||
|
|
||||||
private function post_deployment()
|
private function post_deployment()
|
||||||
{
|
{
|
||||||
GetContainersStatus::dispatch($this->server);
|
// Mark deployment as complete FIRST, before any other operations
|
||||||
|
// This ensures the deployment status is FINISHED even if subsequent operations fail
|
||||||
$this->completeDeployment();
|
$this->completeDeployment();
|
||||||
|
|
||||||
|
// Then handle side effects - these should not fail the deployment
|
||||||
|
try {
|
||||||
|
GetContainersStatus::dispatch($this->server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
if ($this->application->is_github_based()) {
|
if ($this->application->is_github_based()) {
|
||||||
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
|
try {
|
||||||
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->run_post_deployment_command();
|
|
||||||
$this->application->isConfigurationChanged(true);
|
try {
|
||||||
|
$this->run_post_deployment_command();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->application->isConfigurationChanged(true);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deploy_simple_dockerfile()
|
private function deploy_simple_dockerfile()
|
||||||
|
|
@ -1945,7 +1968,7 @@ private function prepare_builder_image(bool $firstTry = true)
|
||||||
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
|
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
$runCommand,
|
$runCommand,
|
||||||
|
|
@ -1960,8 +1983,8 @@ private function prepare_builder_image(bool $firstTry = true)
|
||||||
|
|
||||||
private function restart_builder_container_with_actual_commit()
|
private function restart_builder_container_with_actual_commit()
|
||||||
{
|
{
|
||||||
// Stop and remove the current helper container
|
// Stop the current helper container (no need for rm -f as it was started with --rm)
|
||||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
||||||
|
|
||||||
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
|
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
|
||||||
$this->env_args = null;
|
$this->env_args = null;
|
||||||
|
|
@ -3148,14 +3171,20 @@ private function build_image()
|
||||||
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function graceful_shutdown_container(string $containerName)
|
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$timeout = isDev() ? 1 : 30;
|
$timeout = isDev() ? 1 : 30;
|
||||||
$this->execute_remote_command(
|
if ($skipRemove) {
|
||||||
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
$this->execute_remote_command(
|
||||||
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
$this->execute_remote_command(
|
||||||
|
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
||||||
|
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (Exception $error) {
|
} catch (Exception $error) {
|
||||||
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
||||||
}
|
}
|
||||||
|
|
@ -3934,13 +3963,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if deployment is in a terminal state (FAILED or CANCELLED).
|
* Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
|
||||||
* Terminal states cannot be changed.
|
* Terminal states cannot be changed.
|
||||||
*/
|
*/
|
||||||
private function isInTerminalState(): bool
|
private function isInTerminalState(): bool
|
||||||
{
|
{
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
|
|
||||||
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -3980,6 +4013,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
|
||||||
*/
|
*/
|
||||||
private function handleSuccessfulDeployment(): void
|
private function handleSuccessfulDeployment(): void
|
||||||
{
|
{
|
||||||
|
// Reset restart count after successful deployment
|
||||||
|
// This is done here (not in Livewire) to avoid race conditions
|
||||||
|
// with GetContainersStatus reading old container restart counts
|
||||||
|
$this->application->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||||
|
|
||||||
if (! $this->only_this_server) {
|
if (! $this->only_this_server) {
|
||||||
|
|
|
||||||
55
app/Jobs/ConnectProxyToNetworksJob.php
Normal file
55
app/Jobs/ConnectProxyToNetworksJob.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
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\Middleware\WithoutOverlapping;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously connects the coolify-proxy to Docker networks.
|
||||||
|
*
|
||||||
|
* This job is dispatched from PushServerUpdateJob when the proxy is found running
|
||||||
|
* to ensure it's connected to all required networks without blocking the status update.
|
||||||
|
*/
|
||||||
|
class ConnectProxyToNetworksJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
public $timeout = 60;
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
// Prevent overlapping executions for the same server and throttle to max once per 10 seconds
|
||||||
|
return [
|
||||||
|
(new WithoutOverlapping('connect-proxy-networks-'.$this->server->uuid))
|
||||||
|
->expireAfter(60)
|
||||||
|
->dontRelease(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(public Server $server) {}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
if (! $this->server->isFunctional()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
||||||
|
|
||||||
|
if (empty($connectProxyToDockerNetworks)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use App\Actions\Server\StartLogDrain;
|
use App\Actions\Server\StartLogDrain;
|
||||||
use App\Actions\Shared\ComplexStatusCheck;
|
use App\Actions\Shared\ComplexStatusCheck;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationPreview;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
|
|
@ -134,29 +135,29 @@ public function handle()
|
||||||
if ($this->containers->isEmpty()) {
|
if ($this->containers->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applications = $this->server->applications();
|
$this->applications = $this->server->applications();
|
||||||
$this->databases = $this->server->databases();
|
$this->databases = $this->server->databases();
|
||||||
$this->previews = $this->server->previews();
|
$this->previews = $this->server->previews();
|
||||||
$this->services = $this->server->services()->get();
|
// Eager load service applications and databases to avoid N+1 queries
|
||||||
|
$this->services = $this->server->services()
|
||||||
|
->with(['applications:id,service_id', 'databases:id,service_id'])
|
||||||
|
->get();
|
||||||
|
|
||||||
$this->allApplicationIds = $this->applications->filter(function ($application) {
|
$this->allApplicationIds = $this->applications->filter(function ($application) {
|
||||||
return $application->additional_servers->count() === 0;
|
return $application->additional_servers_count === 0;
|
||||||
})->pluck('id');
|
})->pluck('id');
|
||||||
$this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) {
|
$this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) {
|
||||||
return $application->additional_servers->count() > 0;
|
return $application->additional_servers_count > 0;
|
||||||
});
|
});
|
||||||
$this->allApplicationPreviewsIds = $this->previews->map(function ($preview) {
|
$this->allApplicationPreviewsIds = $this->previews->map(function ($preview) {
|
||||||
return $preview->application_id.':'.$preview->pull_request_id;
|
return $preview->application_id.':'.$preview->pull_request_id;
|
||||||
});
|
});
|
||||||
$this->allDatabaseUuids = $this->databases->pluck('uuid');
|
$this->allDatabaseUuids = $this->databases->pluck('uuid');
|
||||||
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
|
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
|
||||||
$this->services->each(function ($service) {
|
// Use eager-loaded relationships instead of querying in loop
|
||||||
$service->applications()->pluck('id')->each(function ($applicationId) {
|
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
|
||||||
$this->allServiceApplicationIds->push($applicationId);
|
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
|
||||||
});
|
|
||||||
$service->databases()->pluck('id')->each(function ($databaseId) {
|
|
||||||
$this->allServiceDatabaseIds->push($databaseId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach ($this->containers as $container) {
|
foreach ($this->containers as $container) {
|
||||||
$containerStatus = data_get($container, 'state', 'exited');
|
$containerStatus = data_get($container, 'state', 'exited');
|
||||||
|
|
@ -236,8 +237,9 @@ public function handle()
|
||||||
$this->foundProxy = true;
|
$this->foundProxy = true;
|
||||||
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
||||||
} else {
|
} else {
|
||||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
if ($this->allDatabaseUuids->contains($uuid) && $this->isActiveOrTransient($containerStatus)) {
|
||||||
$this->foundDatabaseUuids->push($uuid);
|
$this->foundDatabaseUuids->push($uuid);
|
||||||
|
// TCP proxy should only be started/managed when database is actually running
|
||||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -402,66 +404,59 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p
|
||||||
private function updateNotFoundApplicationStatus()
|
private function updateNotFoundApplicationStatus()
|
||||||
{
|
{
|
||||||
$notFoundApplicationIds = $this->allApplicationIds->diff($this->foundApplicationIds);
|
$notFoundApplicationIds = $this->allApplicationIds->diff($this->foundApplicationIds);
|
||||||
if ($notFoundApplicationIds->isNotEmpty()) {
|
if ($notFoundApplicationIds->isEmpty()) {
|
||||||
$notFoundApplicationIds->each(function ($applicationId) {
|
return;
|
||||||
$application = Application::find($applicationId);
|
|
||||||
if ($application) {
|
|
||||||
// Don't mark as exited if already exited
|
|
||||||
if (str($application->status)->startsWith('exited')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only protection: Verify we received any container data at all
|
|
||||||
// If containers collection is completely empty, Sentinel might have failed
|
|
||||||
if ($this->containers->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($application->status !== 'exited') {
|
|
||||||
$application->status = 'exited';
|
|
||||||
$application->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only protection: Verify we received any container data at all
|
||||||
|
// If containers collection is completely empty, Sentinel might have failed
|
||||||
|
if ($this->containers->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update: mark all not-found applications as exited (excluding already exited ones)
|
||||||
|
Application::whereIn('id', $notFoundApplicationIds)
|
||||||
|
->where('status', 'not like', 'exited%')
|
||||||
|
->update(['status' => 'exited']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateNotFoundApplicationPreviewStatus()
|
private function updateNotFoundApplicationPreviewStatus()
|
||||||
{
|
{
|
||||||
$notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds);
|
$notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds);
|
||||||
if ($notFoundApplicationPreviewsIds->isNotEmpty()) {
|
if ($notFoundApplicationPreviewsIds->isEmpty()) {
|
||||||
$notFoundApplicationPreviewsIds->each(function ($previewKey) {
|
return;
|
||||||
// Parse the previewKey format "application_id:pull_request_id"
|
}
|
||||||
$parts = explode(':', $previewKey);
|
|
||||||
if (count($parts) !== 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$applicationId = $parts[0];
|
// Only protection: Verify we received any container data at all
|
||||||
$pullRequestId = $parts[1];
|
// If containers collection is completely empty, Sentinel might have failed
|
||||||
|
if ($this->containers->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$applicationPreview = $this->previews->where('application_id', $applicationId)
|
// Collect IDs of previews that need to be marked as exited
|
||||||
->where('pull_request_id', $pullRequestId)
|
$previewIdsToUpdate = collect();
|
||||||
->first();
|
foreach ($notFoundApplicationPreviewsIds as $previewKey) {
|
||||||
|
// Parse the previewKey format "application_id:pull_request_id"
|
||||||
|
$parts = explode(':', $previewKey);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($applicationPreview) {
|
$applicationId = $parts[0];
|
||||||
// Don't mark as exited if already exited
|
$pullRequestId = $parts[1];
|
||||||
if (str($applicationPreview->status)->startsWith('exited')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only protection: Verify we received any container data at all
|
$applicationPreview = $this->previews->where('application_id', $applicationId)
|
||||||
// If containers collection is completely empty, Sentinel might have failed
|
->where('pull_request_id', $pullRequestId)
|
||||||
if ($this->containers->isEmpty()) {
|
->first();
|
||||||
|
|
||||||
return;
|
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
|
||||||
}
|
$previewIdsToUpdate->push($applicationPreview->id);
|
||||||
if ($applicationPreview->status !== 'exited') {
|
}
|
||||||
$applicationPreview->status = 'exited';
|
}
|
||||||
$applicationPreview->save();
|
|
||||||
}
|
// Batch update all collected preview IDs
|
||||||
}
|
if ($previewIdsToUpdate->isNotEmpty()) {
|
||||||
});
|
ApplicationPreview::whereIn('id', $previewIdsToUpdate)->update(['status' => 'exited']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,8 +473,8 @@ private function updateProxyStatus()
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
// Connect proxy to networks asynchronously to avoid blocking the status update
|
||||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
ConnectProxyToNetworksJob::dispatch($this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -509,20 +504,28 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
|
||||||
private function updateNotFoundDatabaseStatus()
|
private function updateNotFoundDatabaseStatus()
|
||||||
{
|
{
|
||||||
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
|
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
|
||||||
if ($notFoundDatabaseUuids->isNotEmpty()) {
|
if ($notFoundDatabaseUuids->isEmpty()) {
|
||||||
$notFoundDatabaseUuids->each(function ($databaseUuid) {
|
return;
|
||||||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
|
||||||
if ($database) {
|
|
||||||
if ($database->status !== 'exited') {
|
|
||||||
$database->status = 'exited';
|
|
||||||
$database->save();
|
|
||||||
}
|
|
||||||
if ($database->is_public) {
|
|
||||||
StopDatabaseProxy::dispatch($database);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only protection: Verify we received any container data at all
|
||||||
|
// If containers collection is completely empty, Sentinel might have failed
|
||||||
|
if ($this->containers->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notFoundDatabaseUuids->each(function ($databaseUuid) {
|
||||||
|
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
||||||
|
if ($database) {
|
||||||
|
if (! str($database->status)->startsWith('exited')) {
|
||||||
|
$database->status = 'exited';
|
||||||
|
$database->save();
|
||||||
|
}
|
||||||
|
if ($database->is_public) {
|
||||||
|
StopDatabaseProxy::dispatch($database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
|
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
|
||||||
|
|
@ -554,27 +557,19 @@ private function updateNotFoundServiceStatus()
|
||||||
{
|
{
|
||||||
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
|
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
|
||||||
$notFoundServiceDatabaseIds = $this->allServiceDatabaseIds->diff($this->foundServiceDatabaseIds);
|
$notFoundServiceDatabaseIds = $this->allServiceDatabaseIds->diff($this->foundServiceDatabaseIds);
|
||||||
|
|
||||||
|
// Batch update service applications
|
||||||
if ($notFoundServiceApplicationIds->isNotEmpty()) {
|
if ($notFoundServiceApplicationIds->isNotEmpty()) {
|
||||||
$notFoundServiceApplicationIds->each(function ($serviceApplicationId) {
|
ServiceApplication::whereIn('id', $notFoundServiceApplicationIds)
|
||||||
$application = ServiceApplication::find($serviceApplicationId);
|
->where('status', '!=', 'exited')
|
||||||
if ($application) {
|
->update(['status' => 'exited']);
|
||||||
if ($application->status !== 'exited') {
|
|
||||||
$application->status = 'exited';
|
|
||||||
$application->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch update service databases
|
||||||
if ($notFoundServiceDatabaseIds->isNotEmpty()) {
|
if ($notFoundServiceDatabaseIds->isNotEmpty()) {
|
||||||
$notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) {
|
ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds)
|
||||||
$database = ServiceDatabase::find($serviceDatabaseId);
|
->where('status', '!=', 'exited')
|
||||||
if ($database) {
|
->update(['status' => 'exited']);
|
||||||
if ($database->status !== 'exited') {
|
|
||||||
$database->status = 'exited';
|
|
||||||
$database->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,6 +585,23 @@ private function isRunning(string $containerStatus)
|
||||||
return str($containerStatus)->contains('running');
|
return str($containerStatus)->contains('running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if container is in an active or transient state.
|
||||||
|
* Active states: running
|
||||||
|
* Transient states: restarting, starting, created, paused
|
||||||
|
*
|
||||||
|
* These states indicate the container exists and should be tracked.
|
||||||
|
* Terminal states (exited, dead, removing) should NOT be tracked.
|
||||||
|
*/
|
||||||
|
private function isActiveOrTransient(string $containerStatus): bool
|
||||||
|
{
|
||||||
|
return str($containerStatus)->contains('running') ||
|
||||||
|
str($containerStatus)->contains('restarting') ||
|
||||||
|
str($containerStatus)->contains('starting') ||
|
||||||
|
str($containerStatus)->contains('created') ||
|
||||||
|
str($containerStatus)->contains('paused');
|
||||||
|
}
|
||||||
|
|
||||||
private function checkLogDrainContainer()
|
private function checkLogDrainContainer()
|
||||||
{
|
{
|
||||||
if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) {
|
if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) {
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,7 @@ public function handle()
|
||||||
} else {
|
} else {
|
||||||
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
|
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
|
||||||
$this->server->save();
|
$this->server->save();
|
||||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
ConnectProxyToNetworksJob::dispatchSync($this->server);
|
||||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,13 +160,10 @@ private function processServerTasks(Server $server): void
|
||||||
ServerPatchCheckJob::dispatch($server);
|
ServerPatchCheckJob::dispatch($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for sentinel updates hourly (independent of user-configurable update_check_frequency)
|
// Sentinel update checks (hourly) - check for updates to Sentinel version
|
||||||
if ($server->isSentinelEnabled()) {
|
// No timezone needed for hourly - runs at top of every hour
|
||||||
$shouldCheckSentinel = $this->shouldRunNow('0 * * * *', $serverTimezone);
|
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
|
||||||
|
CheckAndStartSentinelJob::dispatch($server);
|
||||||
if ($shouldCheckSentinel) {
|
|
||||||
CheckAndStartSentinelJob::dispatch($server);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
92
app/Jobs/SyncStripeSubscriptionsJob.php
Normal file
92
app/Jobs/SyncStripeSubscriptionsJob.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class SyncStripeSubscriptionsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 1800; // 30 minutes max
|
||||||
|
|
||||||
|
public function __construct(public bool $fix = false)
|
||||||
|
{
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): array
|
||||||
|
{
|
||||||
|
if (! isCloud() || ! isStripe()) {
|
||||||
|
return ['error' => 'Not running on Cloud or Stripe not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscriptions = Subscription::whereNotNull('stripe_subscription_id')
|
||||||
|
->where('stripe_invoice_paid', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||||
|
$discrepancies = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($subscriptions as $subscription) {
|
||||||
|
try {
|
||||||
|
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||||
|
$subscription->stripe_subscription_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if Stripe says cancelled but we think it's active
|
||||||
|
if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) {
|
||||||
|
$discrepancies[] = [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'team_id' => $subscription->team_id,
|
||||||
|
'stripe_subscription_id' => $subscription->stripe_subscription_id,
|
||||||
|
'stripe_status' => $stripeSubscription->status,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only fix if --fix flag is passed
|
||||||
|
if ($this->fix) {
|
||||||
|
$subscription->update([
|
||||||
|
'stripe_invoice_paid' => false,
|
||||||
|
'stripe_past_due' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stripeSubscription->status === 'canceled') {
|
||||||
|
$subscription->team?->subscriptionEnded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to avoid Stripe rate limits
|
||||||
|
usleep(100000); // 100ms
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errors[] = [
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only notify if discrepancies found and fixed
|
||||||
|
if ($this->fix && count($discrepancies) > 0) {
|
||||||
|
send_internal_notification(
|
||||||
|
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
|
||||||
|
json_encode($discrepancies, JSON_PRETTY_PRINT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_checked' => $subscriptions->count(),
|
||||||
|
'discrepancies' => $discrepancies,
|
||||||
|
'errors' => $errors,
|
||||||
|
'fixed' => $this->fix,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,8 @@ public function handle(ProxyStatusChanged $event)
|
||||||
$server->proxy->set('status', $status);
|
$server->proxy->set('status', $status);
|
||||||
$server->save();
|
$server->save();
|
||||||
|
|
||||||
ProxyStatusChangedUI::dispatch($server->team_id);
|
$versionCheckDispatched = false;
|
||||||
|
|
||||||
if ($status === 'running') {
|
if ($status === 'running') {
|
||||||
$server->setupDefaultRedirect();
|
$server->setupDefaultRedirect();
|
||||||
$server->setupDynamicProxyConfiguration();
|
$server->setupDynamicProxyConfiguration();
|
||||||
|
|
@ -40,7 +41,9 @@ public function handle(ProxyStatusChanged $event)
|
||||||
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||||
$traefikVersions = get_traefik_versions();
|
$traefikVersions = get_traefik_versions();
|
||||||
if ($traefikVersions !== null) {
|
if ($traefikVersions !== null) {
|
||||||
|
// Version check job will dispatch ProxyStatusChangedUI when complete
|
||||||
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
|
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
|
||||||
|
$versionCheckDispatched = true;
|
||||||
} else {
|
} else {
|
||||||
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
|
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
|
|
@ -49,6 +52,13 @@ public function handle(ProxyStatusChanged $event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only dispatch UI refresh if version check wasn't dispatched
|
||||||
|
// (version check job handles its own UI refresh with updated version data)
|
||||||
|
if (! $versionCheckDispatched) {
|
||||||
|
ProxyStatusChangedUI::dispatch($server->team_id);
|
||||||
|
}
|
||||||
|
|
||||||
if ($status === 'created') {
|
if ($status === 'created') {
|
||||||
instant_remote_process([
|
instant_remote_process([
|
||||||
'docker rm -f coolify-proxy',
|
'docker rm -f coolify-proxy',
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,10 @@ public function polling()
|
||||||
$causer_id = data_get($this->activity, 'causer_id');
|
$causer_id = data_get($this->activity, 'causer_id');
|
||||||
$user = User::find($causer_id);
|
$user = User::find($causer_id);
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$teamId = $user->currentTeam()->id;
|
$teamId = data_get($this->activity, 'properties.team_id')
|
||||||
if (! self::$eventDispatched) {
|
?? $user->currentTeam()?->id
|
||||||
|
?? $user->teams->first()?->id;
|
||||||
|
if ($teamId && ! self::$eventDispatched) {
|
||||||
if (filled($this->eventData)) {
|
if (filled($this->eventData)) {
|
||||||
$this->eventToDispatch::dispatch($teamId, $this->eventData);
|
$this->eventToDispatch::dispatch($teamId, $this->eventData);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ public function deploymentCount()
|
||||||
return $this->deployments->count();
|
return $this->deployments->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function shouldReduceOpacity(): bool
|
||||||
|
{
|
||||||
|
return request()->routeIs('project.application.deployment.*');
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleExpanded()
|
public function toggleExpanded()
|
||||||
{
|
{
|
||||||
$this->expanded = ! $this->expanded;
|
$this->expanded = ! $this->expanded;
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ public function submit()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->redirect(route('destination.show', $docker->uuid));
|
redirectRoute($this, 'destination.show', [$docker->uuid]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1314,15 +1314,10 @@ private function completeResourceCreation()
|
||||||
'server_id' => $this->selectedServerId,
|
'server_id' => $this->selectedServerId,
|
||||||
];
|
];
|
||||||
|
|
||||||
// PostgreSQL requires a database_image parameter
|
redirectRoute($this, 'project.resource.create', [
|
||||||
if ($this->selectedResourceType === 'postgresql') {
|
|
||||||
$queryParams['database_image'] = 'postgres:16-alpine';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect(route('project.resource.create', [
|
|
||||||
'project_uuid' => $this->selectedProjectUuid,
|
'project_uuid' => $this->selectedProjectUuid,
|
||||||
'environment_uuid' => $this->selectedEnvironmentUuid,
|
'environment_uuid' => $this->selectedEnvironmentUuid,
|
||||||
] + $queryParams));
|
] + $queryParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1341,6 +1336,42 @@ public function cancelResourceSelection()
|
||||||
$this->autoOpenResource = null;
|
$this->autoOpenResource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function goBack()
|
||||||
|
{
|
||||||
|
// From Environment Selection → go back to Project (if multiple) or further
|
||||||
|
if ($this->selectedProjectUuid !== null) {
|
||||||
|
$this->selectedProjectUuid = null;
|
||||||
|
$this->selectedEnvironmentUuid = null;
|
||||||
|
if (count($this->availableProjects) > 1) {
|
||||||
|
return; // Stop here - user can choose a project
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From Project Selection → go back to Destination (if multiple) or further
|
||||||
|
if ($this->selectedDestinationUuid !== null) {
|
||||||
|
$this->selectedDestinationUuid = null;
|
||||||
|
$this->selectedProjectUuid = null;
|
||||||
|
$this->selectedEnvironmentUuid = null;
|
||||||
|
if (count($this->availableDestinations) > 1) {
|
||||||
|
return; // Stop here - user can choose a destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From Destination Selection → go back to Server (if multiple) or cancel
|
||||||
|
if ($this->selectedServerId !== null) {
|
||||||
|
$this->selectedServerId = null;
|
||||||
|
$this->selectedDestinationUuid = null;
|
||||||
|
$this->selectedProjectUuid = null;
|
||||||
|
$this->selectedEnvironmentUuid = null;
|
||||||
|
if (count($this->availableServers) > 1) {
|
||||||
|
return; // Stop here - user can choose a server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All previous steps were auto-selected, cancel entirely
|
||||||
|
$this->cancelResourceSelection();
|
||||||
|
}
|
||||||
|
|
||||||
public function getFilteredCreatableItemsProperty()
|
public function getFilteredCreatableItemsProperty()
|
||||||
{
|
{
|
||||||
$query = strtolower(trim($this->searchQuery));
|
$query = strtolower(trim($this->searchQuery));
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class NavbarDeleteTeam extends Component
|
class NavbarDeleteTeam extends Component
|
||||||
|
|
@ -19,12 +17,8 @@ public function mount()
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentTeam = currentTeam();
|
$currentTeam = currentTeam();
|
||||||
|
|
@ -43,7 +37,7 @@ public function delete($password)
|
||||||
|
|
||||||
refreshSession();
|
refreshSession();
|
||||||
|
|
||||||
return redirect()->route('team.index');
|
return redirectRoute($this, 'team.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $is_debug_enabled = false;
|
public bool $is_debug_enabled = false;
|
||||||
|
|
||||||
|
public bool $fullscreen = false;
|
||||||
|
|
||||||
|
private bool $deploymentFinishedDispatched = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
'refreshQueue',
|
'refreshQueue',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -91,24 +92,52 @@ private function isKeepAliveOn()
|
||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->dispatch('deploymentFinished');
|
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||||
$this->isKeepAliveOn();
|
$this->isKeepAliveOn();
|
||||||
|
|
||||||
|
// Dispatch event when deployment finishes to stop auto-scroll (only once)
|
||||||
|
if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
|
||||||
|
$this->deploymentFinishedDispatched = true;
|
||||||
|
$this->dispatch('deploymentFinished');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLogLinesProperty()
|
public function getLogLinesProperty()
|
||||||
{
|
{
|
||||||
return decode_remote_command_output($this->application_deployment_queue)->map(function ($logLine) {
|
return decode_remote_command_output($this->application_deployment_queue);
|
||||||
$logLine['line'] = e($logLine['line']);
|
}
|
||||||
$logLine['line'] = preg_replace(
|
|
||||||
'/(https?:\/\/[^\s]+)/',
|
|
||||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-neutral-400">$1</a>',
|
|
||||||
$logLine['line'],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $logLine;
|
public function copyLogs(): string
|
||||||
});
|
{
|
||||||
|
$logs = decode_remote_command_output($this->application_deployment_queue)
|
||||||
|
->map(function ($line) {
|
||||||
|
return $line['timestamp'].' '.
|
||||||
|
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
|
||||||
|
trim($line['line']);
|
||||||
|
})
|
||||||
|
->join("\n");
|
||||||
|
|
||||||
|
return sanitizeLogsForExport($logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadAllLogs(): string
|
||||||
|
{
|
||||||
|
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
|
||||||
|
->map(function ($line) {
|
||||||
|
$prefix = '';
|
||||||
|
if ($line['hidden']) {
|
||||||
|
$prefix = '[DEBUG] ';
|
||||||
|
}
|
||||||
|
if (isset($line['command']) && $line['command']) {
|
||||||
|
$prefix .= '[CMD]: ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $line['timestamp'].' '.$prefix.trim($line['line']);
|
||||||
|
})
|
||||||
|
->join("\n");
|
||||||
|
|
||||||
|
return sanitizeLogsForExport($logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -558,8 +558,11 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
|
||||||
$this->dispatch('refreshStorages');
|
$this->dispatch('refreshStorages');
|
||||||
$this->dispatch('refreshEnvs');
|
$this->dispatch('refreshEnvs');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
|
// Refresh model to get restored values from Application::loadComposeFile
|
||||||
$this->application->save();
|
$this->application->refresh();
|
||||||
|
// Sync restored values back to component properties for UI update
|
||||||
|
|
||||||
|
$this->syncData();
|
||||||
|
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -936,73 +939,6 @@ public function downloadConfig()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateServiceEnvironmentVariables()
|
|
||||||
{
|
|
||||||
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
|
|
||||||
|
|
||||||
foreach ($domains as $serviceName => $service) {
|
|
||||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
|
||||||
$domain = data_get($service, 'domain');
|
|
||||||
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
|
|
||||||
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
|
||||||
->where('resourceable_id', $this->application->id)
|
|
||||||
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
|
||||||
->where('resourceable_id', $this->application->id)
|
|
||||||
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
if ($domain) {
|
|
||||||
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
|
|
||||||
$fqdn = Url::fromString($domain);
|
|
||||||
$port = $fqdn->getPort();
|
|
||||||
$path = $fqdn->getPath();
|
|
||||||
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
|
|
||||||
if ($path !== '/') {
|
|
||||||
$urlValue = $urlValue.$path;
|
|
||||||
}
|
|
||||||
$fqdnValue = str($domain)->after('://');
|
|
||||||
if ($path !== '/') {
|
|
||||||
$fqdnValue = $fqdnValue.$path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/update SERVICE_FQDN_
|
|
||||||
$this->application->environment_variables()->updateOrCreate([
|
|
||||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
|
|
||||||
], [
|
|
||||||
'value' => $fqdnValue,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create/update SERVICE_URL_
|
|
||||||
$this->application->environment_variables()->updateOrCreate([
|
|
||||||
'key' => "SERVICE_URL_{$serviceNameFormatted}",
|
|
||||||
], [
|
|
||||||
'value' => $urlValue,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
// Create/update port-specific variables if port exists
|
|
||||||
if (filled($port)) {
|
|
||||||
$this->application->environment_variables()->updateOrCreate([
|
|
||||||
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
|
|
||||||
], [
|
|
||||||
'value' => $fqdnValue,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->application->environment_variables()->updateOrCreate([
|
|
||||||
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
|
|
||||||
], [
|
|
||||||
'value' => $urlValue,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDetectedPortInfoProperty(): ?array
|
public function getDetectedPortInfoProperty(): ?array
|
||||||
{
|
{
|
||||||
$detectedPort = $this->application->detectPortFromEnvironment();
|
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false)
|
||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
force_rebuild: $force_rebuild,
|
force_rebuild: $force_rebuild,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset restart count on successful deployment
|
|
||||||
$this->application->update([
|
|
||||||
'restart_count' => 0,
|
|
||||||
'last_restart_at' => null,
|
|
||||||
'last_restart_type' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|
@ -151,19 +149,17 @@ public function restart()
|
||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
restart_only: true,
|
restart_only: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset restart count on manual restart
|
|
||||||
$this->application->update([
|
|
||||||
'restart_count' => 0,
|
|
||||||
'last_restart_at' => now(),
|
|
||||||
'last_restart_type' => 'manual',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,11 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu
|
||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
git_type: $found->git_type ?? null,
|
git_type: $found->git_type ?? null,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ public function rollbackImage($commit)
|
||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $this->application,
|
application: $this->application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: $commit,
|
commit: $commit,
|
||||||
|
|
@ -60,7 +60,13 @@ public function rollbackImage($commit)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('project.application.deployment.show', [
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectRoute($this, 'project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
'deployment_uuid' => $deployment_uuid,
|
'deployment_uuid' => $deployment_uuid,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -154,12 +151,8 @@ public function delete($password)
|
||||||
{
|
{
|
||||||
$this->authorize('manageBackups', $this->backup->database);
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class BackupExecutions extends Component
|
class BackupExecutions extends Component
|
||||||
|
|
@ -69,12 +67,8 @@ public function cleanupDeleted()
|
||||||
|
|
||||||
public function deleteBackup($executionId, $password)
|
public function deleteBackup($executionId, $password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ public function delete()
|
||||||
if ($environment->isEmpty()) {
|
if ($environment->isEmpty()) {
|
||||||
$environment->delete();
|
$environment->delete();
|
||||||
|
|
||||||
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
|
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
|
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ public function delete()
|
||||||
if ($project->isEmpty()) {
|
if ($project->isEmpty()) {
|
||||||
$project->delete();
|
$project->delete();
|
||||||
|
|
||||||
return redirect()->route('project.index');
|
return redirectRoute($this, 'project.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
|
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->redirectRoute('project.environment.edit', [
|
redirectRoute($this, 'project.environment.edit', [
|
||||||
'environment_uuid' => $this->environment->uuid,
|
'environment_uuid' => $this->environment->uuid,
|
||||||
'project_uuid' => $this->project->uuid,
|
'project_uuid' => $this->project->uuid,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ public function submit()
|
||||||
'fqdn' => $fqdn,
|
'fqdn' => $fqdn,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('project.application.configuration', [
|
return redirectRoute($this, 'project.application.configuration', [
|
||||||
'application_uuid' => $application->uuid,
|
'application_uuid' => $application->uuid,
|
||||||
'environment_uuid' => $environment->uuid,
|
'environment_uuid' => $environment->uuid,
|
||||||
'project_uuid' => $project->uuid,
|
'project_uuid' => $project->uuid,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,6 @@ public function createEmptyProject()
|
||||||
'uuid' => (string) new Cuid2,
|
'uuid' => (string) new Cuid2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
|
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ public function loadBranches()
|
||||||
$this->loadBranchByPage();
|
$this->loadBranchByPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$this->branches = sortBranchesByPriority($this->branches);
|
||||||
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
|
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ class Select extends Component
|
||||||
|
|
||||||
protected $queryString = [
|
protected $queryString = [
|
||||||
'server_id',
|
'server_id',
|
||||||
|
'type' => ['except' => ''],
|
||||||
|
'destination_uuid' => ['except' => '', 'as' => 'destination'],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
|
|
@ -66,6 +68,20 @@ public function mount()
|
||||||
$project = Project::whereUuid($projectUuid)->firstOrFail();
|
$project = Project::whereUuid($projectUuid)->firstOrFail();
|
||||||
$this->environments = $project->environments;
|
$this->environments = $project->environments;
|
||||||
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
|
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
|
||||||
|
|
||||||
|
// Check if we have all required params for PostgreSQL type selection
|
||||||
|
// This handles navigation from global search
|
||||||
|
$queryType = request()->query('type');
|
||||||
|
$queryServerId = request()->query('server_id');
|
||||||
|
$queryDestination = request()->query('destination');
|
||||||
|
|
||||||
|
if ($queryType === 'postgresql' && $queryServerId !== null && $queryDestination) {
|
||||||
|
$this->type = $queryType;
|
||||||
|
$this->server_id = $queryServerId;
|
||||||
|
$this->destination_uuid = $queryDestination;
|
||||||
|
$this->server = Server::find($queryServerId);
|
||||||
|
$this->current_step = 'select-postgresql-type';
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ public function mount()
|
||||||
|
|
||||||
if (in_array($type, DATABASE_TYPES)) {
|
if (in_array($type, DATABASE_TYPES)) {
|
||||||
if ($type->value() === 'postgresql') {
|
if ($type->value() === 'postgresql') {
|
||||||
|
// PostgreSQL requires database_image to be explicitly set
|
||||||
|
// If not provided, fall through to Select component for version selection
|
||||||
|
if (! $database_image) {
|
||||||
|
$this->type = $type->value();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
$database = create_standalone_postgresql(
|
$database = create_standalone_postgresql(
|
||||||
environmentId: $environment->id,
|
environmentId: $environment->id,
|
||||||
destinationUuid: $destination_uuid,
|
destinationUuid: $destination_uuid,
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Project\Resource;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class EnvironmentSelect extends Component
|
|
||||||
{
|
|
||||||
public Collection $environments;
|
|
||||||
|
|
||||||
public string $project_uuid = '';
|
|
||||||
|
|
||||||
public string $selectedEnvironment = '';
|
|
||||||
|
|
||||||
public function mount()
|
|
||||||
{
|
|
||||||
$this->selectedEnvironment = request()->route('environment_uuid');
|
|
||||||
$this->project_uuid = request()->route('project_uuid');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSelectedEnvironment($value)
|
|
||||||
{
|
|
||||||
if ($value === 'edit') {
|
|
||||||
return redirect()->route('project.show', [
|
|
||||||
'project_uuid' => $this->project_uuid,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
return redirect()->route('project.resource.index', [
|
|
||||||
'project_uuid' => $this->project_uuid,
|
|
||||||
'environment_uuid' => $value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,12 +4,9 @@
|
||||||
|
|
||||||
use App\Actions\Database\StartDatabaseProxy;
|
use App\Actions\Database\StartDatabaseProxy;
|
||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Database extends Component
|
class Database extends Component
|
||||||
|
|
@ -96,18 +93,14 @@ public function delete($password)
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->database);
|
$this->authorize('delete', $this->database);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->database->delete();
|
$this->database->delete();
|
||||||
$this->dispatch('success', 'Database deleted.');
|
$this->dispatch('success', 'Database deleted.');
|
||||||
|
|
||||||
return redirect()->route('project.service.configuration', $this->parameters);
|
return redirectRoute($this, 'project.service.configuration', $this->parameters);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +164,7 @@ public function convertToApplication()
|
||||||
$serviceDatabase->delete();
|
$serviceDatabase->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect()->route('project.service.configuration', $redirectParams);
|
return redirectRoute($this, 'project.service.configuration', $redirectParams);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Livewire\Project\Service;
|
namespace App\Livewire\Project\Service;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\LocalFileVolume;
|
use App\Models\LocalFileVolume;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
|
|
@ -16,8 +15,6 @@
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -62,7 +59,7 @@ public function mount()
|
||||||
$this->fs_path = $this->fileStorage->fs_path;
|
$this->fs_path = $this->fileStorage->fs_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
|
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +101,8 @@ public function convertToDirectory()
|
||||||
public function loadStorageOnServer()
|
public function loadStorageOnServer()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->resource);
|
// Loading content is a read operation, so we use 'view' permission
|
||||||
|
$this->authorize('view', $this->resource);
|
||||||
|
|
||||||
$this->fileStorage->loadStorageOnServer();
|
$this->fileStorage->loadStorageOnServer();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
|
|
@ -140,12 +138,8 @@ public function delete($password)
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Service;
|
namespace App\Livewire\Project\Service;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
|
|
@ -128,12 +125,8 @@ public function delete($password)
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->application);
|
$this->authorize('delete', $this->application);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->application->delete();
|
$this->application->delete();
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
|
||||||
public function refreshStorages()
|
public function refreshStorages()
|
||||||
{
|
{
|
||||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||||
$this->resource->refresh();
|
$this->resource->load('persistentStorages.resource');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFilesProperty()
|
public function getFilesProperty()
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,10 @@
|
||||||
namespace App\Livewire\Project\Shared;
|
namespace App\Livewire\Project\Shared;
|
||||||
|
|
||||||
use App\Jobs\DeleteResourceJob;
|
use App\Jobs\DeleteResourceJob;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
|
|
@ -93,12 +90,8 @@ public function mount()
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->resource) {
|
if (! $this->resource) {
|
||||||
|
|
@ -118,7 +111,7 @@ public function delete($password)
|
||||||
$this->docker_cleanup
|
$this->docker_cleanup
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('project.resource.index', [
|
return redirectRoute($this, 'project.resource.index', [
|
||||||
'project_uuid' => $this->projectUuid,
|
'project_uuid' => $this->projectUuid,
|
||||||
'environment_uuid' => $this->environmentUuid,
|
'environment_uuid' => $this->environmentUuid,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,9 @@
|
||||||
use App\Actions\Application\StopApplicationOneServer;
|
use App\Actions\Application\StopApplicationOneServer;
|
||||||
use App\Actions\Docker\GetContainersStatus;
|
use App\Actions\Docker\GetContainersStatus;
|
||||||
use App\Events\ApplicationStatusChanged;
|
use App\Events\ApplicationStatusChanged;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
|
|
@ -89,13 +86,18 @@ public function redeploy(int $network_id, int $server_id)
|
||||||
only_this_server: true,
|
only_this_server: true,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('project.application.deployment.show', [
|
return redirectRoute($this, 'project.application.deployment.show', [
|
||||||
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
|
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
|
||||||
'application_uuid' => data_get($this->resource, 'uuid'),
|
'application_uuid' => data_get($this->resource, 'uuid'),
|
||||||
'deployment_uuid' => $deployment_uuid,
|
'deployment_uuid' => $deployment_uuid,
|
||||||
|
|
@ -135,12 +137,8 @@ public function addServer(int $network_id, int $server_id)
|
||||||
public function removeServer(int $network_id, int $server_id, $password)
|
public function removeServer(int $network_id, int $server_id, $password)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
|
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@
|
||||||
|
|
||||||
class GetLogs extends Component
|
class GetLogs extends Component
|
||||||
{
|
{
|
||||||
|
public const MAX_LOG_LINES = 50000;
|
||||||
|
|
||||||
|
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
public string $outputs = '';
|
public string $outputs = '';
|
||||||
|
|
||||||
public string $errors = '';
|
public string $errors = '';
|
||||||
|
|
@ -67,11 +71,6 @@ public function mount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function doSomethingWithThisChunkOfOutput($output)
|
|
||||||
{
|
|
||||||
$this->outputs .= removeAnsiColors($output);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
if (! is_null($this->resource)) {
|
if (! is_null($this->resource)) {
|
||||||
|
|
@ -128,6 +127,9 @@ public function getLogs($refresh = false)
|
||||||
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
|
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
|
||||||
$this->numberOfLines = 1000;
|
$this->numberOfLines = 1000;
|
||||||
}
|
}
|
||||||
|
if ($this->numberOfLines > self::MAX_LOG_LINES) {
|
||||||
|
$this->numberOfLines = self::MAX_LOG_LINES;
|
||||||
|
}
|
||||||
if ($this->container) {
|
if ($this->container) {
|
||||||
if ($this->showTimeStamps) {
|
if ($this->showTimeStamps) {
|
||||||
if ($this->server->isSwarm()) {
|
if ($this->server->isSwarm()) {
|
||||||
|
|
@ -162,23 +164,107 @@ public function getLogs($refresh = false)
|
||||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($refresh) {
|
// Collect new logs into temporary variable first to prevent flickering
|
||||||
$this->outputs = '';
|
// (avoids clearing output before new data is ready)
|
||||||
}
|
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||||
Process::run($sshCommand, function (string $type, string $output) {
|
$logChunks = [];
|
||||||
$this->doSomethingWithThisChunkOfOutput($output);
|
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
|
||||||
|
$logChunks[] = removeAnsiColors($output);
|
||||||
});
|
});
|
||||||
|
$newOutputs = implode('', $logChunks);
|
||||||
|
|
||||||
if ($this->showTimeStamps) {
|
if ($this->showTimeStamps) {
|
||||||
$this->outputs = str($this->outputs)->split('/\n/')->sort(function ($a, $b) {
|
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
|
||||||
$a = explode(' ', $a);
|
$a = explode(' ', $a);
|
||||||
$b = explode(' ', $b);
|
$b = explode(' ', $b);
|
||||||
|
|
||||||
return $a[0] <=> $b[0];
|
return $a[0] <=> $b[0];
|
||||||
})->join("\n");
|
})->join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update outputs after new data is ready (atomic update prevents flicker)
|
||||||
|
$this->outputs = $newOutputs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function copyLogs(): string
|
||||||
|
{
|
||||||
|
return sanitizeLogsForExport($this->outputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadAllLogs(): string
|
||||||
|
{
|
||||||
|
if (! $this->server->isFunctional() || ! $this->container) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->showTimeStamps) {
|
||||||
|
if ($this->server->isSwarm()) {
|
||||||
|
$command = "docker service logs -t {$this->container}";
|
||||||
|
} else {
|
||||||
|
$command = "docker logs -t {$this->container}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($this->server->isSwarm()) {
|
||||||
|
$command = "docker service logs {$this->container}";
|
||||||
|
} else {
|
||||||
|
$command = "docker logs {$this->container}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->server->isNonRoot()) {
|
||||||
|
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||||
|
$command = $command[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||||
|
|
||||||
|
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||||
|
// Enforce 50MB size limit to prevent memory exhaustion from large logs
|
||||||
|
$logChunks = [];
|
||||||
|
$accumulatedBytes = 0;
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
|
||||||
|
if ($truncated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = removeAnsiColors($output);
|
||||||
|
$outputBytes = strlen($output);
|
||||||
|
|
||||||
|
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
|
||||||
|
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
|
||||||
|
if ($remaining > 0) {
|
||||||
|
$logChunks[] = substr($output, 0, $remaining);
|
||||||
|
}
|
||||||
|
$truncated = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logChunks[] = $output;
|
||||||
|
$accumulatedBytes += $outputBytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
$allLogs = implode('', $logChunks);
|
||||||
|
|
||||||
|
if ($truncated) {
|
||||||
|
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->showTimeStamps) {
|
||||||
|
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
|
||||||
|
$a = explode(' ', $a);
|
||||||
|
$b = explode(' ', $b);
|
||||||
|
|
||||||
|
return $a[0] <=> $b[0];
|
||||||
|
})->join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeLogsForExport($allLogs);
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project.shared.get-logs');
|
return view('livewire.project.shared.get-logs');
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Shared\Storages;
|
namespace App\Livewire\Project\Shared\Storages;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\LocalPersistentVolume;
|
use App\Models\LocalPersistentVolume;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Show extends Component
|
class Show extends Component
|
||||||
|
|
@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->syncData(false);
|
$this->syncData(false);
|
||||||
$this->isReadOnly = $this->storage->isReadOnlyVolume();
|
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -84,12 +81,8 @@ public function delete($password)
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->storage->delete();
|
$this->storage->delete();
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,14 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
|
||||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||||
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
||||||
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
|
|
||||||
|
// Add sudo for non-root users to access Docker socket
|
||||||
|
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
|
||||||
|
if ($server->isNonRoot()) {
|
||||||
|
$dockerCommand = "sudo {$dockerCommand}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
|
||||||
} else {
|
} else {
|
||||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ public function submit()
|
||||||
'uuid' => (string) new Cuid2,
|
'uuid' => (string) new Cuid2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('project.resource.index', [
|
return redirectRoute($this, 'project.resource.index', [
|
||||||
'project_uuid' => $this->project->uuid,
|
'project_uuid' => $this->project->uuid,
|
||||||
'environment_uuid' => $environment->uuid,
|
'environment_uuid' => $environment->uuid,
|
||||||
]);
|
]);
|
||||||
|
|
@ -59,7 +59,7 @@ public function submit()
|
||||||
|
|
||||||
public function navigateToEnvironment($projectUuid, $environmentUuid)
|
public function navigateToEnvironment($projectUuid, $environmentUuid)
|
||||||
{
|
{
|
||||||
return redirect()->route('project.resource.index', [
|
return redirectRoute($this, 'project.resource.index', [
|
||||||
'project_uuid' => $projectUuid,
|
'project_uuid' => $projectUuid,
|
||||||
'environment_uuid' => $environmentUuid,
|
'environment_uuid' => $environmentUuid,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ private function validatePrivateKey()
|
||||||
private function redirectAfterCreation(PrivateKey $privateKey)
|
private function redirectAfterCreation(PrivateKey $privateKey)
|
||||||
{
|
{
|
||||||
return $this->from === 'server'
|
return $this->from === 'server'
|
||||||
? redirect()->route('dashboard')
|
? redirectRoute($this, 'dashboard')
|
||||||
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
|
: redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ public function delete()
|
||||||
$this->private_key->safeDelete();
|
$this->private_key->safeDelete();
|
||||||
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
|
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
|
||||||
|
|
||||||
return redirect()->route('security.private-key.index');
|
return redirectRoute($this, 'security.private-key.index');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->dispatch('error', $e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class Advanced extends Component
|
||||||
#[Validate(['integer', 'min:1'])]
|
#[Validate(['integer', 'min:1'])]
|
||||||
public int $dynamicTimeout = 1;
|
public int $dynamicTimeout = 1;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:1'])]
|
||||||
|
public int $deploymentQueueLimit = 25;
|
||||||
|
|
||||||
public function mount(string $server_uuid)
|
public function mount(string $server_uuid)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -43,12 +46,14 @@ public function syncData(bool $toModel = false)
|
||||||
$this->validate();
|
$this->validate();
|
||||||
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
|
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
|
||||||
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
|
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
|
||||||
|
$this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit;
|
||||||
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
|
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
|
||||||
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
|
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
|
||||||
$this->server->settings->save();
|
$this->server->settings->save();
|
||||||
} else {
|
} else {
|
||||||
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
|
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
|
||||||
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
|
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
|
||||||
|
$this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit;
|
||||||
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
|
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
|
||||||
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
|
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@
|
||||||
namespace App\Livewire\Server;
|
namespace App\Livewire\Server;
|
||||||
|
|
||||||
use App\Actions\Server\DeleteServer;
|
use App\Actions\Server\DeleteServer;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Delete extends Component
|
class Delete extends Component
|
||||||
|
|
@ -29,12 +26,8 @@ public function mount(string $server_uuid)
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->server);
|
$this->authorize('delete', $this->server);
|
||||||
|
|
@ -53,7 +46,7 @@ public function delete($password)
|
||||||
$this->server->team_id
|
$this->server->team_id
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('server.index');
|
return redirectRoute($this, 'server.index');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Server;
|
namespace App\Livewire\Server;
|
||||||
|
|
||||||
|
use App\Jobs\ConnectProxyToNetworksJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use App\Models\SwarmDocker;
|
use App\Models\SwarmDocker;
|
||||||
|
|
@ -29,8 +30,7 @@ public function mount(string $server_uuid)
|
||||||
|
|
||||||
private function createNetworkAndAttachToProxy()
|
private function createNetworkAndAttachToProxy()
|
||||||
{
|
{
|
||||||
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
|
ConnectProxyToNetworksJob::dispatchSync($this->server);
|
||||||
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add($name)
|
public function add($name)
|
||||||
|
|
|
||||||
|
|
@ -567,10 +567,10 @@ public function submit()
|
||||||
]);
|
]);
|
||||||
refreshSession();
|
refreshSession();
|
||||||
|
|
||||||
return $this->redirect(route('server.show', $server->uuid));
|
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('server.show', $server->uuid);
|
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ public function submit()
|
||||||
$server->settings->is_build_server = $this->is_build_server;
|
$server->settings->is_build_server = $this->is_build_server;
|
||||||
$server->settings->save();
|
$server->settings->save();
|
||||||
|
|
||||||
return redirect()->route('server.show', $server->uuid);
|
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Enums\ProxyTypes;
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Rules\ValidProxyConfigFilename;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
@ -38,11 +39,11 @@ public function addDynamicConfiguration()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->server);
|
$this->authorize('update', $this->server);
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'fileName' => 'required',
|
'fileName' => ['required', new ValidProxyConfigFilename],
|
||||||
'value' => 'required',
|
'value' => 'required',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Validate filename to prevent command injection
|
// Additional security validation to prevent command injection
|
||||||
validateShellSafePath($this->fileName, 'proxy configuration filename');
|
validateShellSafePath($this->fileName, 'proxy configuration filename');
|
||||||
|
|
||||||
if (data_get($this->parameters, 'server_uuid')) {
|
if (data_get($this->parameters, 'server_uuid')) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Resources extends Component
|
class Resources extends Component
|
||||||
|
|
@ -15,7 +14,7 @@ class Resources extends Component
|
||||||
|
|
||||||
public $parameters = [];
|
public $parameters = [];
|
||||||
|
|
||||||
public Collection $containers;
|
public array $unmanagedContainers = [];
|
||||||
|
|
||||||
public $activeTab = 'managed';
|
public $activeTab = 'managed';
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ public function loadManagedContainers()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->activeTab = 'managed';
|
$this->activeTab = 'managed';
|
||||||
$this->containers = $this->server->refresh()->definedResources();
|
$this->server->refresh();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +73,7 @@ public function loadUnmanagedContainers()
|
||||||
{
|
{
|
||||||
$this->activeTab = 'unmanaged';
|
$this->activeTab = 'unmanaged';
|
||||||
try {
|
try {
|
||||||
$this->containers = $this->server->loadUnmanagedContainers();
|
$this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -82,14 +81,12 @@ public function loadUnmanagedContainers()
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->containers = collect();
|
|
||||||
$this->parameters = get_route_parameters();
|
$this->parameters = get_route_parameters();
|
||||||
try {
|
try {
|
||||||
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first();
|
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first();
|
||||||
if (is_null($this->server)) {
|
if (is_null($this->server)) {
|
||||||
return redirect()->route('server.index');
|
return redirect()->route('server.index');
|
||||||
}
|
}
|
||||||
$this->loadManagedContainers();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
|
|
||||||
namespace App\Livewire\Server\Security;
|
namespace App\Livewire\Server\Security;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -44,13 +41,9 @@ public function toggleTerminal($password)
|
||||||
throw new \Exception('Only team administrators and owners can modify terminal access.');
|
throw new \Exception('Only team administrators and owners can modify terminal access.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password unless two-step confirmation is disabled
|
// Verify password
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the terminal setting
|
// Toggle the terminal setting
|
||||||
|
|
|
||||||
172
app/Livewire/Server/Sentinel.php
Normal file
172
app/Livewire/Server/Sentinel.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Server;
|
||||||
|
|
||||||
|
use App\Actions\Server\StartSentinel;
|
||||||
|
use App\Actions\Server\StopSentinel;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Sentinel extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public Server $server;
|
||||||
|
|
||||||
|
public array $parameters = [];
|
||||||
|
|
||||||
|
public bool $isMetricsEnabled;
|
||||||
|
|
||||||
|
#[Validate(['required'])]
|
||||||
|
public string $sentinelToken;
|
||||||
|
|
||||||
|
public ?string $sentinelUpdatedAt = null;
|
||||||
|
|
||||||
|
#[Validate(['required', 'integer', 'min:1'])]
|
||||||
|
public int $sentinelMetricsRefreshRateSeconds;
|
||||||
|
|
||||||
|
#[Validate(['required', 'integer', 'min:1'])]
|
||||||
|
public int $sentinelMetricsHistoryDays;
|
||||||
|
|
||||||
|
#[Validate(['required', 'integer', 'min:10'])]
|
||||||
|
public int $sentinelPushIntervalSeconds;
|
||||||
|
|
||||||
|
#[Validate(['nullable', 'url'])]
|
||||||
|
public ?string $sentinelCustomUrl = null;
|
||||||
|
|
||||||
|
public bool $isSentinelEnabled;
|
||||||
|
|
||||||
|
public bool $isSentinelDebugEnabled;
|
||||||
|
|
||||||
|
public ?string $sentinelCustomDockerImage = null;
|
||||||
|
|
||||||
|
public function getListeners()
|
||||||
|
{
|
||||||
|
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(string $server_uuid)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||||
|
$this->parameters = get_route_parameters();
|
||||||
|
$this->syncData();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return redirect()->route('server.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncData(bool $toModel = false)
|
||||||
|
{
|
||||||
|
if ($toModel) {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
$this->validate();
|
||||||
|
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
|
||||||
|
$this->server->settings->sentinel_token = $this->sentinelToken;
|
||||||
|
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
|
||||||
|
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
|
||||||
|
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
|
||||||
|
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
|
||||||
|
$this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled;
|
||||||
|
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
|
||||||
|
$this->server->settings->save();
|
||||||
|
} else {
|
||||||
|
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
|
||||||
|
$this->sentinelToken = $this->server->settings->sentinel_token;
|
||||||
|
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
|
||||||
|
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
|
||||||
|
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
|
||||||
|
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
|
||||||
|
$this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled;
|
||||||
|
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||||
|
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleSentinelRestarted($event)
|
||||||
|
{
|
||||||
|
if ($event['serverUuid'] === $this->server->uuid) {
|
||||||
|
$this->server->refresh();
|
||||||
|
$this->syncData();
|
||||||
|
$this->dispatch('success', 'Sentinel has been restarted successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restartSentinel()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('manageSentinel', $this->server);
|
||||||
|
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||||
|
$this->server->restartSentinel($customImage);
|
||||||
|
$this->dispatch('info', 'Restarting Sentinel.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedIsSentinelEnabled($value)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('manageSentinel', $this->server);
|
||||||
|
if ($value === true) {
|
||||||
|
if ($this->server->isBuildServer()) {
|
||||||
|
$this->isSentinelEnabled = false;
|
||||||
|
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||||
|
StartSentinel::run($this->server, true, null, $customImage);
|
||||||
|
} else {
|
||||||
|
$this->isMetricsEnabled = false;
|
||||||
|
$this->isSentinelDebugEnabled = false;
|
||||||
|
StopSentinel::dispatch($this->server);
|
||||||
|
}
|
||||||
|
$this->submit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateSentinelToken()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('manageSentinel', $this->server);
|
||||||
|
$this->server->settings->generateSentinelToken();
|
||||||
|
$this->dispatch('success', 'Token regenerated. Restarting Sentinel.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->syncData(true);
|
||||||
|
$this->dispatch('success', 'Sentinel settings updated.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function instantSave()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->syncData(true);
|
||||||
|
$this->restartSentinel();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.server.sentinel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,12 @@
|
||||||
use App\Actions\Server\StartSentinel;
|
use App\Actions\Server\StartSentinel;
|
||||||
use App\Actions\Server\StopSentinel;
|
use App\Actions\Server\StopSentinel;
|
||||||
use App\Events\ServerReachabilityChanged;
|
use App\Events\ServerReachabilityChanged;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\HetznerService;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -73,6 +76,19 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $isValidating = false;
|
public bool $isValidating = false;
|
||||||
|
|
||||||
|
// Hetzner linking properties
|
||||||
|
public Collection $availableHetznerTokens;
|
||||||
|
|
||||||
|
public ?int $selectedHetznerTokenId = null;
|
||||||
|
|
||||||
|
public ?string $manualHetznerServerId = null;
|
||||||
|
|
||||||
|
public ?array $matchedHetznerServer = null;
|
||||||
|
|
||||||
|
public ?string $hetznerSearchError = null;
|
||||||
|
|
||||||
|
public bool $hetznerNoMatchFound = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||||
|
|
@ -150,6 +166,9 @@ public function mount(string $server_uuid)
|
||||||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||||
$this->isValidating = $this->server->is_validating ?? false;
|
$this->isValidating = $this->server->is_validating ?? false;
|
||||||
|
|
||||||
|
// Load Hetzner tokens for linking
|
||||||
|
$this->loadHetznerTokens();
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +449,10 @@ public function handleServerValidated($event = null)
|
||||||
|
|
||||||
// Update validation state
|
// Update validation state
|
||||||
$this->isValidating = $this->server->is_validating ?? false;
|
$this->isValidating = $this->server->is_validating ?? false;
|
||||||
|
|
||||||
|
// Reload Hetzner tokens in case the linking section should now be shown
|
||||||
|
$this->loadHetznerTokens();
|
||||||
|
|
||||||
$this->dispatch('refreshServerShow');
|
$this->dispatch('refreshServerShow');
|
||||||
$this->dispatch('refreshServer');
|
$this->dispatch('refreshServer');
|
||||||
}
|
}
|
||||||
|
|
@ -465,6 +488,140 @@ public function submit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loadHetznerTokens(): void
|
||||||
|
{
|
||||||
|
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchHetznerServer(): void
|
||||||
|
{
|
||||||
|
$this->hetznerSearchError = null;
|
||||||
|
$this->hetznerNoMatchFound = false;
|
||||||
|
$this->matchedHetznerServer = null;
|
||||||
|
|
||||||
|
if (! $this->selectedHetznerTokenId) {
|
||||||
|
$this->hetznerSearchError = 'Please select a Hetzner token.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
|
||||||
|
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
|
||||||
|
if (! $token) {
|
||||||
|
$this->hetznerSearchError = 'Invalid token selected.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$matched = $hetznerService->findServerByIp($this->server->ip);
|
||||||
|
|
||||||
|
if ($matched) {
|
||||||
|
$this->matchedHetznerServer = $matched;
|
||||||
|
} else {
|
||||||
|
$this->hetznerNoMatchFound = true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchHetznerServerById(): void
|
||||||
|
{
|
||||||
|
$this->hetznerSearchError = null;
|
||||||
|
$this->hetznerNoMatchFound = false;
|
||||||
|
$this->matchedHetznerServer = null;
|
||||||
|
|
||||||
|
if (! $this->selectedHetznerTokenId) {
|
||||||
|
$this->hetznerSearchError = 'Please select a Hetzner token first.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->manualHetznerServerId) {
|
||||||
|
$this->hetznerSearchError = 'Please enter a Hetzner Server ID.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
|
||||||
|
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
|
||||||
|
if (! $token) {
|
||||||
|
$this->hetznerSearchError = 'Invalid token selected.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$serverData = $hetznerService->getServer((int) $this->manualHetznerServerId);
|
||||||
|
|
||||||
|
if (! empty($serverData)) {
|
||||||
|
$this->matchedHetznerServer = $serverData;
|
||||||
|
} else {
|
||||||
|
$this->hetznerNoMatchFound = true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->hetznerSearchError = 'Failed to fetch Hetzner server: '.$e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function linkToHetzner()
|
||||||
|
{
|
||||||
|
if (! $this->matchedHetznerServer) {
|
||||||
|
$this->dispatch('error', 'No Hetzner server selected.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
|
||||||
|
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
|
||||||
|
if (! $token) {
|
||||||
|
$this->dispatch('error', 'Invalid token selected.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the server exists and is accessible with the token
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
|
||||||
|
|
||||||
|
if (empty($serverData)) {
|
||||||
|
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the server with Hetzner details
|
||||||
|
$this->server->update([
|
||||||
|
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
|
||||||
|
'hetzner_server_id' => $this->matchedHetznerServer['id'],
|
||||||
|
'hetzner_server_status' => $serverData['status'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
||||||
|
|
||||||
|
// Clear the linking state
|
||||||
|
$this->matchedHetznerServer = null;
|
||||||
|
$this->selectedHetznerTokenId = null;
|
||||||
|
$this->manualHetznerServerId = null;
|
||||||
|
$this->hetznerNoMatchFound = false;
|
||||||
|
$this->hetznerSearchError = null;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
|
||||||
|
$this->dispatch('refreshServerShow');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.server.show');
|
return view('livewire.server.show');
|
||||||
|
|
|
||||||
59
app/Livewire/Server/Swarm.php
Normal file
59
app/Livewire/Server/Swarm.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Server;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Swarm extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public Server $server;
|
||||||
|
|
||||||
|
public array $parameters = [];
|
||||||
|
|
||||||
|
public bool $isSwarmManager;
|
||||||
|
|
||||||
|
public bool $isSwarmWorker;
|
||||||
|
|
||||||
|
public function mount(string $server_uuid)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||||
|
$this->parameters = get_route_parameters();
|
||||||
|
$this->syncData();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return redirect()->route('server.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncData(bool $toModel = false)
|
||||||
|
{
|
||||||
|
if ($toModel) {
|
||||||
|
$this->authorize('update', $this->server);
|
||||||
|
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
|
||||||
|
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
|
||||||
|
$this->server->settings->save();
|
||||||
|
} else {
|
||||||
|
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
|
||||||
|
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function instantSave()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->syncData(true);
|
||||||
|
$this->dispatch('success', 'Swarm settings updated.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.server.swarm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,12 @@
|
||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Rules\ValidIpOrCidr;
|
use App\Rules\ValidIpOrCidr;
|
||||||
use Auth;
|
|
||||||
use Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Advanced extends Component
|
class Advanced extends Component
|
||||||
{
|
{
|
||||||
#[Validate('required')]
|
|
||||||
public Server $server;
|
|
||||||
|
|
||||||
public InstanceSettings $settings;
|
public InstanceSettings $settings;
|
||||||
|
|
||||||
#[Validate('boolean')]
|
#[Validate('boolean')]
|
||||||
|
|
@ -40,10 +34,12 @@ class Advanced extends Component
|
||||||
#[Validate('boolean')]
|
#[Validate('boolean')]
|
||||||
public bool $disable_two_step_confirmation;
|
public bool $disable_two_step_confirmation;
|
||||||
|
|
||||||
|
#[Validate('boolean')]
|
||||||
|
public bool $is_wire_navigate_enabled;
|
||||||
|
|
||||||
public function rules()
|
public function rules()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'server' => 'required',
|
|
||||||
'is_registration_enabled' => 'boolean',
|
'is_registration_enabled' => 'boolean',
|
||||||
'do_not_track' => 'boolean',
|
'do_not_track' => 'boolean',
|
||||||
'is_dns_validation_enabled' => 'boolean',
|
'is_dns_validation_enabled' => 'boolean',
|
||||||
|
|
@ -52,6 +48,7 @@ public function rules()
|
||||||
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
|
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
|
||||||
'is_sponsorship_popup_enabled' => 'boolean',
|
'is_sponsorship_popup_enabled' => 'boolean',
|
||||||
'disable_two_step_confirmation' => 'boolean',
|
'disable_two_step_confirmation' => 'boolean',
|
||||||
|
'is_wire_navigate_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +57,6 @@ public function mount()
|
||||||
if (! isInstanceAdmin()) {
|
if (! isInstanceAdmin()) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
$this->server = Server::findOrFail(0);
|
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->custom_dns_servers = $this->settings->custom_dns_servers;
|
$this->custom_dns_servers = $this->settings->custom_dns_servers;
|
||||||
$this->allowed_ips = $this->settings->allowed_ips;
|
$this->allowed_ips = $this->settings->allowed_ips;
|
||||||
|
|
@ -70,6 +66,7 @@ public function mount()
|
||||||
$this->is_api_enabled = $this->settings->is_api_enabled;
|
$this->is_api_enabled = $this->settings->is_api_enabled;
|
||||||
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
|
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
|
||||||
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
|
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
|
||||||
|
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -148,6 +145,7 @@ public function instantSave()
|
||||||
$this->settings->allowed_ips = $this->allowed_ips;
|
$this->settings->allowed_ips = $this->allowed_ips;
|
||||||
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
|
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
|
||||||
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
|
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
|
||||||
|
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
$this->dispatch('success', 'Settings updated!');
|
$this->dispatch('success', 'Settings updated!');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -157,9 +155,7 @@ public function instantSave()
|
||||||
|
|
||||||
public function toggleTwoStepConfirmation($password): bool
|
public function toggleTwoStepConfirmation($password): bool
|
||||||
{
|
{
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class Index extends Component
|
||||||
{
|
{
|
||||||
public InstanceSettings $settings;
|
public InstanceSettings $settings;
|
||||||
|
|
||||||
public Server $server;
|
public ?Server $server = null;
|
||||||
|
|
||||||
#[Validate('nullable|string|max:255')]
|
#[Validate('nullable|string|max:255')]
|
||||||
public ?string $fqdn = null;
|
public ?string $fqdn = null;
|
||||||
|
|
@ -57,7 +57,9 @@ public function mount()
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->server = Server::findOrFail(0);
|
if (! isCloud()) {
|
||||||
|
$this->server = Server::findOrFail(0);
|
||||||
|
}
|
||||||
$this->fqdn = $this->settings->fqdn;
|
$this->fqdn = $this->settings->fqdn;
|
||||||
$this->public_port_min = $this->settings->public_port_min;
|
$this->public_port_min = $this->settings->public_port_min;
|
||||||
$this->public_port_max = $this->settings->public_port_max;
|
$this->public_port_max = $this->settings->public_port_max;
|
||||||
|
|
@ -80,7 +82,7 @@ public function timezones(): array
|
||||||
public function instantSave($isSave = true)
|
public function instantSave($isSave = true)
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
$this->settings->fqdn = $this->fqdn;
|
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
|
||||||
$this->settings->public_port_min = $this->public_port_min;
|
$this->settings->public_port_min = $this->public_port_min;
|
||||||
$this->settings->public_port_max = $this->public_port_max;
|
$this->settings->public_port_max = $this->public_port_max;
|
||||||
$this->settings->instance_name = $this->instance_name;
|
$this->settings->instance_name = $this->instance_name;
|
||||||
|
|
@ -119,9 +121,15 @@ public function submit()
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trim FQDN to remove leading/trailing whitespace before validation
|
||||||
|
if ($this->fqdn) {
|
||||||
|
$this->fqdn = trim($this->fqdn);
|
||||||
|
}
|
||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
|
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
|
||||||
if (! validateDNSEntry($this->fqdn, $this->server)) {
|
if (! validateDNSEntry($this->fqdn, $this->server)) {
|
||||||
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||||
$error_show = true;
|
$error_show = true;
|
||||||
|
|
@ -145,7 +153,9 @@ public function submit()
|
||||||
$this->instantSave(isSave: false);
|
$this->instantSave(isSave: false);
|
||||||
|
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
$this->server->setupDynamicProxyConfiguration();
|
if ($this->server) {
|
||||||
|
$this->server->setupDynamicProxyConfiguration();
|
||||||
|
}
|
||||||
if (! $error_show) {
|
if (! $error_show) {
|
||||||
$this->dispatch('success', 'Instance settings updated successfully!');
|
$this->dispatch('success', 'Instance settings updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +173,12 @@ public function buildHelperImage()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->server) {
|
||||||
|
$this->dispatch('error', 'Server not available.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
|
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
|
||||||
if (empty($version)) {
|
if (empty($version)) {
|
||||||
$this->dispatch('error', 'Please specify a version to build.');
|
$this->dispatch('error', 'Please specify a version to build.');
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class Updates extends Component
|
||||||
{
|
{
|
||||||
public InstanceSettings $settings;
|
public InstanceSettings $settings;
|
||||||
|
|
||||||
public Server $server;
|
public ?Server $server = null;
|
||||||
|
|
||||||
#[Validate('string')]
|
#[Validate('string')]
|
||||||
public string $auto_update_frequency;
|
public string $auto_update_frequency;
|
||||||
|
|
@ -25,7 +25,9 @@ class Updates extends Component
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->server = Server::findOrFail(0);
|
if (! isCloud()) {
|
||||||
|
$this->server = Server::findOrFail(0);
|
||||||
|
}
|
||||||
|
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->auto_update_frequency = $this->settings->auto_update_frequency;
|
$this->auto_update_frequency = $this->settings->auto_update_frequency;
|
||||||
|
|
@ -76,7 +78,9 @@ public function submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->instantSave();
|
$this->instantSave();
|
||||||
$this->server->setupDynamicProxyConfiguration();
|
if ($this->server) {
|
||||||
|
$this->server->setupDynamicProxyConfiguration();
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ public function createGitHubApp()
|
||||||
session(['from' => session('from') + ['source_id' => $github_app->id]]);
|
session(['from' => session('from') + ['source_id' => $github_app->id]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ public function submit()
|
||||||
$this->storage->testConnection();
|
$this->storage->testConnection();
|
||||||
$this->storage->save();
|
$this->storage->save();
|
||||||
|
|
||||||
return redirect()->route('storage.show', $this->storage->uuid);
|
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
|
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
|
||||||
// return handleError($e, $this);
|
// return handleError($e, $this);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Team;
|
namespace App\Livewire\Team;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class AdminView extends Component
|
class AdminView extends Component
|
||||||
|
|
@ -58,12 +55,8 @@ public function delete($id, $password)
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! auth()->user()->isInstanceAdmin()) {
|
if (! auth()->user()->isInstanceAdmin()) {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ public function submit()
|
||||||
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
||||||
refreshSession($team);
|
refreshSession($team);
|
||||||
|
|
||||||
return redirect()->route('team.index');
|
return redirectRoute($this, 'team.index');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ private function generateInviteLink(bool $sendEmail = false)
|
||||||
|
|
||||||
// Prevent privilege escalation: users cannot invite someone with higher privileges
|
// Prevent privilege escalation: users cannot invite someone with higher privileges
|
||||||
$userRole = auth()->user()->role();
|
$userRole = auth()->user()->role();
|
||||||
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
|
if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) {
|
||||||
throw new \Exception('Members cannot invite admins or owners.');
|
throw new \Exception('Members cannot invite admins or owners.');
|
||||||
}
|
}
|
||||||
if ($userRole === 'admin' && $this->role === 'owner') {
|
if ($userRole === 'admin' && $this->role === 'owner') {
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,11 @@ public function remove()
|
||||||
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
|
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
|
||||||
throw new \Exception('You are not authorized to perform this action.');
|
throw new \Exception('You are not authorized to perform this action.');
|
||||||
}
|
}
|
||||||
|
$teamId = currentTeam()->id;
|
||||||
$this->member->teams()->detach(currentTeam());
|
$this->member->teams()->detach(currentTeam());
|
||||||
|
// Clear cache for the removed user - both old and new key formats
|
||||||
Cache::forget("team:{$this->member->id}");
|
Cache::forget("team:{$this->member->id}");
|
||||||
Cache::remember('team:'.$this->member->id, 3600, function () {
|
Cache::forget("user:{$this->member->id}:team:{$teamId}");
|
||||||
return $this->member->teams()->first();
|
|
||||||
});
|
|
||||||
$this->dispatch('reloadWindow');
|
$this->dispatch('reloadWindow');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->dispatch('error', $e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Actions\Server\UpdateCoolify;
|
use App\Actions\Server\UpdateCoolify;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Server;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Upgrade extends Component
|
class Upgrade extends Component
|
||||||
|
|
@ -14,12 +15,23 @@ class Upgrade extends Component
|
||||||
|
|
||||||
public string $latestVersion = '';
|
public string $latestVersion = '';
|
||||||
|
|
||||||
|
public string $currentVersion = '';
|
||||||
|
|
||||||
|
public bool $devMode = false;
|
||||||
|
|
||||||
protected $listeners = ['updateAvailable' => 'checkUpdate'];
|
protected $listeners = ['updateAvailable' => 'checkUpdate'];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->currentVersion = config('constants.coolify.version');
|
||||||
|
$this->devMode = isDev();
|
||||||
|
}
|
||||||
|
|
||||||
public function checkUpdate()
|
public function checkUpdate()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->latestVersion = get_latest_version_of_coolify();
|
$this->latestVersion = get_latest_version_of_coolify();
|
||||||
|
$this->currentVersion = config('constants.coolify.version');
|
||||||
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
|
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->isUpgradeAvailable = true;
|
$this->isUpgradeAvailable = true;
|
||||||
|
|
@ -41,4 +53,71 @@ public function upgrade()
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUpgradeStatus(): array
|
||||||
|
{
|
||||||
|
// Only root team members can view upgrade status
|
||||||
|
if (auth()->user()?->currentTeam()?->id !== 0) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = Server::find(0);
|
||||||
|
if (! $server) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusFile = '/data/coolify/source/.upgrade-status';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = instant_remote_process(
|
||||||
|
["cat {$statusFile} 2>/dev/null || echo ''"],
|
||||||
|
$server,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$content = trim($content ?? '');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($content)) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('|', $content);
|
||||||
|
if (count($parts) < 3) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$step, $message, $timestamp] = $parts;
|
||||||
|
|
||||||
|
// Check if status is stale (older than 10 minutes)
|
||||||
|
try {
|
||||||
|
$statusTime = new \DateTime($timestamp);
|
||||||
|
$now = new \DateTime;
|
||||||
|
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
|
||||||
|
|
||||||
|
if ($diffMinutes > 10) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['status' => 'none'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($step === 'error') {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'step' => 0,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stepInt = (int) $step;
|
||||||
|
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'step' => $stepInt,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Services\ConfigurationGenerator;
|
use App\Services\ConfigurationGenerator;
|
||||||
use App\Traits\ClearsGlobalSearchCache;
|
use App\Traits\ClearsGlobalSearchCache;
|
||||||
use App\Traits\HasConfiguration;
|
use App\Traits\HasConfiguration;
|
||||||
|
use App\Traits\HasMetrics;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -111,7 +112,7 @@
|
||||||
|
|
||||||
class Application extends BaseModel
|
class Application extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||||
|
|
||||||
private static $parserVersion = '5';
|
private static $parserVersion = '5';
|
||||||
|
|
||||||
|
|
@ -1584,6 +1585,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
|
||||||
try {
|
try {
|
||||||
$composeFileContent = instant_remote_process($commands, $this->destination->server);
|
$composeFileContent = instant_remote_process($commands, $this->destination->server);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
// Restore original values on failure only
|
||||||
|
$this->docker_compose_location = $initialDockerComposeLocation;
|
||||||
|
$this->base_directory = $initialBaseDirectory;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
if (str($e->getMessage())->contains('No such file')) {
|
if (str($e->getMessage())->contains('No such file')) {
|
||||||
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||||
}
|
}
|
||||||
|
|
@ -1595,9 +1601,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
|
||||||
}
|
}
|
||||||
throw new \RuntimeException($e->getMessage());
|
throw new \RuntimeException($e->getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
$this->docker_compose_location = $initialDockerComposeLocation;
|
// Cleanup only - restoration happens in catch block
|
||||||
$this->base_directory = $initialBaseDirectory;
|
|
||||||
$this->save();
|
|
||||||
$commands = collect([
|
$commands = collect([
|
||||||
"rm -rf /tmp/{$uuid}",
|
"rm -rf /tmp/{$uuid}",
|
||||||
]);
|
]);
|
||||||
|
|
@ -1643,6 +1647,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
|
||||||
'initialDockerComposeLocation' => $this->docker_compose_location,
|
'initialDockerComposeLocation' => $this->docker_compose_location,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
// Restore original values before throwing
|
||||||
|
$this->docker_compose_location = $initialDockerComposeLocation;
|
||||||
|
$this->base_directory = $initialBaseDirectory;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1969,54 +1978,6 @@ public static function getDomainsByUuid(string $uuid): array
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCpuMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
if ($server->isMetricsEnabled()) {
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['percent']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemoryMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
if ($server->isMetricsEnabled()) {
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['used']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLimits(): array
|
public function getLimits(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -77,21 +77,21 @@ public function generate_preview_fqdn()
|
||||||
if ($this->application->fqdn) {
|
if ($this->application->fqdn) {
|
||||||
if (str($this->application->fqdn)->contains(',')) {
|
if (str($this->application->fqdn)->contains(',')) {
|
||||||
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
|
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
|
||||||
$preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]);
|
|
||||||
} else {
|
} else {
|
||||||
$url = Url::fromString($this->application->fqdn);
|
$url = Url::fromString($this->application->fqdn);
|
||||||
if ($this->fqdn) {
|
|
||||||
$preview_fqdn = getFqdnWithoutPort($this->fqdn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$template = $this->application->preview_url_template;
|
$template = $this->application->preview_url_template;
|
||||||
$host = $url->getHost();
|
$host = $url->getHost();
|
||||||
$schema = $url->getScheme();
|
$schema = $url->getScheme();
|
||||||
|
$portInt = $url->getPort();
|
||||||
|
$port = $portInt !== null ? ':'.$portInt : '';
|
||||||
|
$urlPath = $url->getPath();
|
||||||
|
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||||
$random = new Cuid2;
|
$random = new Cuid2;
|
||||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||||
$preview_fqdn = "$schema://$preview_fqdn";
|
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
|
||||||
$this->fqdn = $preview_fqdn;
|
$this->fqdn = $preview_fqdn;
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
@ -147,11 +147,13 @@ public function generate_preview_fqdn_compose()
|
||||||
$schema = $url->getScheme();
|
$schema = $url->getScheme();
|
||||||
$portInt = $url->getPort();
|
$portInt = $url->getPort();
|
||||||
$port = $portInt !== null ? ':'.$portInt : '';
|
$port = $portInt !== null ? ':'.$portInt : '';
|
||||||
|
$urlPath = $url->getPath();
|
||||||
|
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
|
||||||
$random = new Cuid2;
|
$random = new Cuid2;
|
||||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||||
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
|
||||||
$preview_fqdn = "$schema://$preview_fqdn{$port}";
|
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
|
||||||
$preview_domains[] = $preview_fqdn;
|
$preview_domains[] = $preview_fqdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
class CloudProviderToken extends BaseModel
|
||||||
|
|
||||||
class CloudProviderToken extends Model
|
|
||||||
{
|
{
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class InstanceSettings extends Model
|
||||||
'auto_update_frequency' => 'string',
|
'auto_update_frequency' => 'string',
|
||||||
'update_check_frequency' => 'string',
|
'update_check_frequency' => 'string',
|
||||||
'sentinel_token' => 'encrypted',
|
'sentinel_token' => 'encrypted',
|
||||||
|
'is_wire_navigate_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path)
|
||||||
return $query->get()->where('plain_mount_path', $path);
|
return $query->get()->where('plain_mount_path', $path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this volume belongs to a service resource
|
||||||
|
public function isServiceResource(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->resource_type, [
|
||||||
|
'App\Models\ServiceApplication',
|
||||||
|
'App\Models\ServiceDatabase',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this volume should be read-only in the UI
|
||||||
|
// File/directory mounts can be edited even for services
|
||||||
|
public function shouldBeReadOnlyInUI(): bool
|
||||||
|
{
|
||||||
|
// Check for explicit :ro flag in compose (existing logic)
|
||||||
|
return $this->isReadOnlyVolume();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this volume is read-only by parsing the docker-compose content
|
// Check if this volume is read-only by parsing the docker-compose content
|
||||||
public function isReadOnlyVolume(): bool
|
public function isReadOnlyVolume(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -239,22 +256,40 @@ public function isReadOnlyVolume(): bool
|
||||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||||
|
|
||||||
// Check each volume to find a match
|
// Check each volume to find a match
|
||||||
|
// Note: We match on mount_path (container path) only, since fs_path gets transformed
|
||||||
|
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
|
||||||
foreach ($volumes as $volume) {
|
foreach ($volumes as $volume) {
|
||||||
// Volume can be string like "host:container:ro" or "host:container"
|
// Volume can be string like "host:container:ro" or "host:container"
|
||||||
if (is_string($volume)) {
|
if (is_string($volume)) {
|
||||||
$parts = explode(':', $volume);
|
$parts = explode(':', $volume);
|
||||||
|
|
||||||
// Check if this volume matches our fs_path and mount_path
|
// Check if this volume matches our mount_path
|
||||||
if (count($parts) >= 2) {
|
if (count($parts) >= 2) {
|
||||||
$hostPath = $parts[0];
|
|
||||||
$containerPath = $parts[1];
|
$containerPath = $parts[1];
|
||||||
$options = $parts[2] ?? null;
|
$options = $parts[2] ?? null;
|
||||||
|
|
||||||
// Match based on fs_path and mount_path
|
// Match based on mount_path
|
||||||
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
|
// Remove leading slash from mount_path if present for comparison
|
||||||
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
||||||
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
||||||
|
|
||||||
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
||||||
return $options === 'ro';
|
return $options === 'ro';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} elseif (is_array($volume)) {
|
||||||
|
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
|
||||||
|
$containerPath = data_get($volume, 'target');
|
||||||
|
$readOnly = data_get($volume, 'read_only', false);
|
||||||
|
|
||||||
|
// Match based on mount_path
|
||||||
|
// Remove leading slash from mount_path if present for comparison
|
||||||
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
||||||
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
||||||
|
|
||||||
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
||||||
|
return $readOnly === true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
|
||||||
{
|
{
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public function resource()
|
||||||
|
{
|
||||||
|
return $this->morphTo('resource');
|
||||||
|
}
|
||||||
|
|
||||||
public function application()
|
public function application()
|
||||||
{
|
{
|
||||||
return $this->morphTo('resource');
|
return $this->morphTo('resource');
|
||||||
|
|
@ -50,6 +55,54 @@ protected function hostPath(): Attribute
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this volume belongs to a service resource
|
||||||
|
public function isServiceResource(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->resource_type, [
|
||||||
|
'App\Models\ServiceApplication',
|
||||||
|
'App\Models\ServiceDatabase',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this volume belongs to a dockercompose application
|
||||||
|
public function isDockerComposeResource(): bool
|
||||||
|
{
|
||||||
|
if ($this->resource_type !== 'App\Models\Application') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only access relationship if already eager loaded to avoid N+1
|
||||||
|
if (! $this->relationLoaded('resource')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = $this->resource;
|
||||||
|
if (! $application) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data_get($application, 'build_pack') === 'dockercompose';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this volume should be read-only in the UI
|
||||||
|
// Service volumes and dockercompose application volumes are read-only
|
||||||
|
// (users should edit compose file directly)
|
||||||
|
public function shouldBeReadOnlyInUI(): bool
|
||||||
|
{
|
||||||
|
// All service volumes should be read-only in UI
|
||||||
|
if ($this->isServiceResource()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All dockercompose application volumes should be read-only in UI
|
||||||
|
if ($this->isDockerComposeResource()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for explicit :ro flag in compose (existing logic)
|
||||||
|
return $this->isReadOnlyVolume();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this volume is read-only by parsing the docker-compose content
|
// Check if this volume is read-only by parsing the docker-compose content
|
||||||
public function isReadOnlyVolume(): bool
|
public function isReadOnlyVolume(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool
|
||||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||||
|
|
||||||
// Check each volume to find a match
|
// Check each volume to find a match
|
||||||
|
// Note: We match on mount_path (container path) only, since host paths get transformed
|
||||||
foreach ($volumes as $volume) {
|
foreach ($volumes as $volume) {
|
||||||
// Volume can be string like "host:container:ro" or "host:container"
|
// Volume can be string like "host:container:ro" or "host:container"
|
||||||
if (is_string($volume)) {
|
if (is_string($volume)) {
|
||||||
|
|
@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool
|
||||||
return $options === 'ro';
|
return $options === 'ro';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} elseif (is_array($volume)) {
|
||||||
|
// Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
|
||||||
|
$containerPath = data_get($volume, 'target');
|
||||||
|
$readOnly = data_get($volume, 'read_only', false);
|
||||||
|
|
||||||
|
// Match based on mount_path
|
||||||
|
// Remove leading slash from mount_path if present for comparison
|
||||||
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
||||||
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
||||||
|
|
||||||
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
||||||
|
return $readOnly === true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,28 @@ class S3Storage extends BaseModel
|
||||||
'secret' => 'encrypted',
|
'secret' => 'encrypted',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model and register event listeners.
|
||||||
|
*/
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Trim whitespace from credentials before saving to prevent
|
||||||
|
// "Malformed Access Key Id" errors from accidental whitespace in pasted values.
|
||||||
|
// Note: We use the saving event instead of Attribute mutators because key/secret
|
||||||
|
// use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which
|
||||||
|
// would cause issues with the encryption/decryption cycle.
|
||||||
|
static::saving(function (S3Storage $storage) {
|
||||||
|
if ($storage->key !== null) {
|
||||||
|
$storage->key = trim($storage->key);
|
||||||
|
}
|
||||||
|
if ($storage->secret !== null) {
|
||||||
|
$storage->secret = trim($storage->secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static function ownedByCurrentTeam(array $select = ['*'])
|
public static function ownedByCurrentTeam(array $select = ['*'])
|
||||||
{
|
{
|
||||||
$selectArray = collect($select)->concat(['id']);
|
$selectArray = collect($select)->concat(['id']);
|
||||||
|
|
@ -55,6 +77,36 @@ protected function path(): Attribute
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim whitespace from endpoint to prevent malformed URLs.
|
||||||
|
*/
|
||||||
|
protected function endpoint(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
set: fn (?string $value) => $value ? trim($value) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim whitespace from bucket name to prevent connection errors.
|
||||||
|
*/
|
||||||
|
protected function bucket(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
set: fn (?string $value) => $value ? trim($value) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim whitespace from region to prevent connection errors.
|
||||||
|
*/
|
||||||
|
protected function region(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
set: fn (?string $value) => $value ? trim($value) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testConnection(bool $shouldSave = false)
|
public function testConnection(bool $shouldSave = false)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
use App\Notifications\Server\Unreachable;
|
use App\Notifications\Server\Unreachable;
|
||||||
use App\Services\ConfigurationRepository;
|
use App\Services\ConfigurationRepository;
|
||||||
use App\Traits\ClearsGlobalSearchCache;
|
use App\Traits\ClearsGlobalSearchCache;
|
||||||
|
use App\Traits\HasMetrics;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
|
@ -103,7 +104,7 @@
|
||||||
|
|
||||||
class Server extends BaseModel
|
class Server extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
|
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
|
||||||
|
|
||||||
public static $batch_counter = 0;
|
public static $batch_counter = 0;
|
||||||
|
|
||||||
|
|
@ -667,51 +668,6 @@ public function checkSentinel()
|
||||||
CheckAndStartSentinelJob::dispatch($this);
|
CheckAndStartSentinelJob::dispatch($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCpuMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
if ($this->isMetricsEnabled()) {
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false);
|
|
||||||
if (str($cpu)->contains('error')) {
|
|
||||||
$error = json_decode($cpu, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$cpu = json_decode($cpu, true);
|
|
||||||
|
|
||||||
return collect($cpu)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['percent']];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemoryMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
if ($this->isMetricsEnabled()) {
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false);
|
|
||||||
if (str($memory)->contains('error')) {
|
|
||||||
$error = json_decode($memory, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$memory = json_decode($memory, true);
|
|
||||||
$parsedCollection = collect($memory)->map(function ($metric) {
|
|
||||||
$usedPercent = $metric['usedPercent'] ?? 0.0;
|
|
||||||
|
|
||||||
return [(int) $metric['time'], (float) $usedPercent];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDiskUsage(): ?string
|
public function getDiskUsage(): ?string
|
||||||
{
|
{
|
||||||
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);
|
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);
|
||||||
|
|
@ -831,34 +787,67 @@ public function hasDefinedResources()
|
||||||
|
|
||||||
public function databases()
|
public function databases()
|
||||||
{
|
{
|
||||||
return $this->destinations()->map(function ($standaloneDocker) {
|
// Get destination IDs for this server in two efficient queries
|
||||||
$postgresqls = data_get($standaloneDocker, 'postgresqls', collect([]));
|
$standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id');
|
||||||
$redis = data_get($standaloneDocker, 'redis', collect([]));
|
$swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id');
|
||||||
$mongodbs = data_get($standaloneDocker, 'mongodbs', collect([]));
|
|
||||||
$mysqls = data_get($standaloneDocker, 'mysqls', collect([]));
|
|
||||||
$mariadbs = data_get($standaloneDocker, 'mariadbs', collect([]));
|
|
||||||
$keydbs = data_get($standaloneDocker, 'keydbs', collect([]));
|
|
||||||
$dragonflies = data_get($standaloneDocker, 'dragonflies', collect([]));
|
|
||||||
$clickhouses = data_get($standaloneDocker, 'clickhouses', collect([]));
|
|
||||||
|
|
||||||
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
|
$destinationCondition = function ($query) use ($standaloneDockerIds, $swarmDockerIds) {
|
||||||
})->flatten()->filter(function ($item) {
|
$query->where(function ($q) use ($standaloneDockerIds) {
|
||||||
return data_get($item, 'name') !== 'coolify-db';
|
$q->where('destination_type', StandaloneDocker::class)
|
||||||
});
|
->whereIn('destination_id', $standaloneDockerIds);
|
||||||
|
})->orWhere(function ($q) use ($swarmDockerIds) {
|
||||||
|
$q->where('destination_type', SwarmDocker::class)
|
||||||
|
->whereIn('destination_id', $swarmDockerIds);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query each database type with the destination condition
|
||||||
|
$postgresqls = StandalonePostgresql::where($destinationCondition)->get();
|
||||||
|
$redis = StandaloneRedis::where($destinationCondition)->get();
|
||||||
|
$mongodbs = StandaloneMongodb::where($destinationCondition)->get();
|
||||||
|
$mysqls = StandaloneMysql::where($destinationCondition)->get();
|
||||||
|
$mariadbs = StandaloneMariadb::where($destinationCondition)->get();
|
||||||
|
$keydbs = StandaloneKeydb::where($destinationCondition)->get();
|
||||||
|
$dragonflies = StandaloneDragonfly::where($destinationCondition)->get();
|
||||||
|
$clickhouses = StandaloneClickhouse::where($destinationCondition)->get();
|
||||||
|
|
||||||
|
return $postgresqls
|
||||||
|
->concat($redis)
|
||||||
|
->concat($mongodbs)
|
||||||
|
->concat($mysqls)
|
||||||
|
->concat($mariadbs)
|
||||||
|
->concat($keydbs)
|
||||||
|
->concat($dragonflies)
|
||||||
|
->concat($clickhouses)
|
||||||
|
->filter(fn ($item) => data_get($item, 'name') !== 'coolify-db');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applications()
|
public function applications()
|
||||||
{
|
{
|
||||||
$applications = $this->destinations()->map(function ($standaloneDocker) {
|
// Get destination IDs for this server in two efficient queries
|
||||||
return $standaloneDocker->applications;
|
$standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id');
|
||||||
})->flatten();
|
$swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id');
|
||||||
$additionalApplicationIds = DB::table('additional_destinations')->where('server_id', $this->id)->get('application_id');
|
|
||||||
$additionalApplicationIds = collect($additionalApplicationIds)->map(function ($item) {
|
// Query all applications in a single query using polymorphic conditions
|
||||||
return $item->application_id;
|
$applications = Application::where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) {
|
||||||
});
|
$query->where(function ($q) use ($standaloneDockerIds) {
|
||||||
Application::whereIn('id', $additionalApplicationIds)->get()->each(function ($application) use ($applications) {
|
$q->where('destination_type', StandaloneDocker::class)
|
||||||
$applications->push($application);
|
->whereIn('destination_id', $standaloneDockerIds);
|
||||||
});
|
})->orWhere(function ($q) use ($swarmDockerIds) {
|
||||||
|
$q->where('destination_type', SwarmDocker::class)
|
||||||
|
->whereIn('destination_id', $swarmDockerIds);
|
||||||
|
});
|
||||||
|
})->get();
|
||||||
|
|
||||||
|
// Get additional server applications
|
||||||
|
$additionalApplicationIds = DB::table('additional_destinations')
|
||||||
|
->where('server_id', $this->id)
|
||||||
|
->pluck('application_id');
|
||||||
|
|
||||||
|
if ($additionalApplicationIds->isNotEmpty()) {
|
||||||
|
$additionalApps = Application::whereIn('id', $additionalApplicationIds)->get();
|
||||||
|
$applications = $applications->concat($additionalApps);
|
||||||
|
}
|
||||||
|
|
||||||
return $applications;
|
return $applications;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
properties: [
|
properties: [
|
||||||
'id' => ['type' => 'integer'],
|
'id' => ['type' => 'integer'],
|
||||||
'concurrent_builds' => ['type' => 'integer'],
|
'concurrent_builds' => ['type' => 'integer'],
|
||||||
|
'deployment_queue_limit' => ['type' => 'integer'],
|
||||||
'dynamic_timeout' => ['type' => 'integer'],
|
'dynamic_timeout' => ['type' => 'integer'],
|
||||||
'force_disabled' => ['type' => 'boolean'],
|
'force_disabled' => ['type' => 'boolean'],
|
||||||
'force_server_cleanup' => ['type' => 'boolean'],
|
'force_server_cleanup' => ['type' => 'boolean'],
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\ClearsGlobalSearchCache;
|
use App\Traits\ClearsGlobalSearchCache;
|
||||||
|
use App\Traits\HasMetrics;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -10,7 +11,7 @@
|
||||||
|
|
||||||
class StandaloneClickhouse extends BaseModel
|
class StandaloneClickhouse extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|
@ -18,6 +19,9 @@ class StandaloneClickhouse extends BaseModel
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'clickhouse_password' => 'encrypted',
|
'clickhouse_password' => 'encrypted',
|
||||||
|
'restart_count' => 'integer',
|
||||||
|
'last_restart_at' => 'datetime',
|
||||||
|
'last_restart_type' => 'string',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted()
|
protected static function booted()
|
||||||
|
|
@ -25,7 +29,7 @@ protected static function booted()
|
||||||
static::created(function ($database) {
|
static::created(function ($database) {
|
||||||
LocalPersistentVolume::create([
|
LocalPersistentVolume::create([
|
||||||
'name' => 'clickhouse-data-'.$database->uuid,
|
'name' => 'clickhouse-data-'.$database->uuid,
|
||||||
'mount_path' => '/bitnami/clickhouse',
|
'mount_path' => '/var/lib/clickhouse',
|
||||||
'host_path' => null,
|
'host_path' => null,
|
||||||
'resource_id' => $database->id,
|
'resource_id' => $database->id,
|
||||||
'resource_type' => $database->getMorphClass(),
|
'resource_type' => $database->getMorphClass(),
|
||||||
|
|
@ -246,8 +250,9 @@ protected function internalDbUrl(): Attribute
|
||||||
get: function () {
|
get: function () {
|
||||||
$encodedUser = rawurlencode($this->clickhouse_admin_user);
|
$encodedUser = rawurlencode($this->clickhouse_admin_user);
|
||||||
$encodedPass = rawurlencode($this->clickhouse_admin_password);
|
$encodedPass = rawurlencode($this->clickhouse_admin_password);
|
||||||
|
$database = $this->clickhouse_db ?? 'default';
|
||||||
|
|
||||||
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$this->clickhouse_db}";
|
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$database}";
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -263,8 +268,9 @@ protected function externalDbUrl(): Attribute
|
||||||
}
|
}
|
||||||
$encodedUser = rawurlencode($this->clickhouse_admin_user);
|
$encodedUser = rawurlencode($this->clickhouse_admin_user);
|
||||||
$encodedPass = rawurlencode($this->clickhouse_admin_password);
|
$encodedPass = rawurlencode($this->clickhouse_admin_password);
|
||||||
|
$database = $this->clickhouse_db ?? 'default';
|
||||||
|
|
||||||
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->clickhouse_db}";
|
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -315,50 +321,6 @@ public function scheduledBackups()
|
||||||
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCpuMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['percent']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemoryMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['used']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBackupSolutionAvailable()
|
public function isBackupSolutionAvailable()
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Jobs\ConnectProxyToNetworksJob;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
|
|
||||||
class StandaloneDocker extends BaseModel
|
class StandaloneDocker extends BaseModel
|
||||||
|
|
@ -18,8 +19,7 @@ protected static function boot()
|
||||||
instant_remote_process([
|
instant_remote_process([
|
||||||
"docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
|
"docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
|
||||||
], $server, false);
|
], $server, false);
|
||||||
$connectProxyToDockerNetworks = connectProxyToNetworks($server);
|
ConnectProxyToNetworksJob::dispatchSync($server);
|
||||||
instant_remote_process($connectProxyToDockerNetworks, $server, false);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\ClearsGlobalSearchCache;
|
use App\Traits\ClearsGlobalSearchCache;
|
||||||
|
use App\Traits\HasMetrics;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -10,7 +11,7 @@
|
||||||
|
|
||||||
class StandaloneDragonfly extends BaseModel
|
class StandaloneDragonfly extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|
@ -18,6 +19,9 @@ class StandaloneDragonfly extends BaseModel
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'dragonfly_password' => 'encrypted',
|
'dragonfly_password' => 'encrypted',
|
||||||
|
'restart_count' => 'integer',
|
||||||
|
'last_restart_at' => 'datetime',
|
||||||
|
'last_restart_type' => 'string',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted()
|
protected static function booted()
|
||||||
|
|
@ -313,50 +317,6 @@ public function scheduledBackups()
|
||||||
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCpuMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['percent']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemoryMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['used']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBackupSolutionAvailable()
|
public function isBackupSolutionAvailable()
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\ClearsGlobalSearchCache;
|
use App\Traits\ClearsGlobalSearchCache;
|
||||||
|
use App\Traits\HasMetrics;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -10,7 +11,7 @@
|
||||||
|
|
||||||
class StandaloneKeydb extends BaseModel
|
class StandaloneKeydb extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|
@ -18,6 +19,9 @@ class StandaloneKeydb extends BaseModel
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'keydb_password' => 'encrypted',
|
'keydb_password' => 'encrypted',
|
||||||
|
'restart_count' => 'integer',
|
||||||
|
'last_restart_at' => 'datetime',
|
||||||
|
'last_restart_type' => 'string',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted()
|
protected static function booted()
|
||||||
|
|
@ -313,50 +317,6 @@ public function scheduledBackups()
|
||||||
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCpuMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['percent']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemoryMetrics(int $mins = 5)
|
|
||||||
{
|
|
||||||
$server = $this->destination->server;
|
|
||||||
$container_name = $this->uuid;
|
|
||||||
$from = now()->subMinutes($mins)->toIso8601ZuluString();
|
|
||||||
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
|
|
||||||
if (str($metrics)->contains('error')) {
|
|
||||||
$error = json_decode($metrics, true);
|
|
||||||
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
|
|
||||||
if ($error === 'Unauthorized') {
|
|
||||||
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
|
|
||||||
}
|
|
||||||
throw new \Exception($error);
|
|
||||||
}
|
|
||||||
$metrics = json_decode($metrics, true);
|
|
||||||
$parsedCollection = collect($metrics)->map(function ($metric) {
|
|
||||||
return [(int) $metric['time'], (float) $metric['used']];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $parsedCollection->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBackupSolutionAvailable()
|
public function isBackupSolutionAvailable()
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue